Compare commits

...

14 Commits

Author SHA1 Message Date
rustdesk
731df99b0c webrtc: cap concurrent answerer setups, pin the DTLS fingerprint binding with a test
A WebRTC offer reaches the controlled side before any password or accept
prompt, and answering one builds a peer connection that binds a socket per
interface and runs ICE for up to CONNECT_TIMEOUT. A forged TCP punch reuses
the mediator's local port for one connect; a forged offer costs all of that,
and nothing bounded how many could be in flight at once. SESSIONS dedups by
offer fingerprint, which only stops replays of one offer.

spawn_webrtc_answerer now takes one of 16 slots before building the peer
connection and gives it back the moment the bounded wait for the data
channel returns, open or failed; every earlier failure releases it through
the guard's drop. The slot covers the setup an unauthenticated offer makes
this machine pay for, ICE, DTLS and SCTP. From the open channel on the
connection is one like any other, and the connection layer bounds
unauthenticated connections in number and in time for every transport alike
(the login-grace change), a peer that stalls in the identity handshake or
after it included. So this guard stays inside the WebRTC path, sized above
what legitimate controllers reach at once in the seconds ICE takes.

Past the cap the offer is declined with an empty answer, the reply the
controller already gets from a peer without WebRTC, so it carries on over
punch and relay. Declines log through the throttled-log macro. At the cap a
re-sent PunchHole for a live session also gets an empty answer rather than
the cached one, since the slot is taken before the cache is consulted; only
reachable at the cap, where degrading is the point.

The other change is a test. The controller's defence against a rendezvous or
relay that swaps SDP fingerprints is the fingerprint the controlled side signs
into IdPk and the comparison in secure_connection, and neither had a test.
The comparison moves into dtls_fingerprint_bound so it can have one, along
with decode_id_pk_dtls: the fingerprint round-trips under the signature,
another key or an edited payload yields nothing, empty never binds, and
decode_id_pk still sees the same id and pk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
2026-09-16 16:51:53 +08:00
RustDesk
092a961b62 server: bound unauthenticated connections in number and in time (#16237)
A connection that never logs in costs whatever its transport costs, for as
long as it keeps itself alive: the only limit was the 30s idle timeout, which
any message resets. Nothing bounded how many such connections one machine
holds, on any transport. The shape sshd_config answers with LoginGraceTime
and MaxStartups.

Every connection is admitted among the unauthorized ones before its identity
handshake, in create_tcp_connection, and holds that place until it
authorizes or ends: the count of live places is the bound, not a ledger
beside the connections: the resource bound. One address may hold sixteen, a
quarter of the room; a further connection from it is refused before the
handshake. That share is a fairness cap against the cheapest flood, one host
with one address, not a security boundary: any pool of addresses passes it,
and the global limit is what holds. With 64 held in all, a further arrival
is refused too, and the oldest connection is told to go, unless one is on
its way out already: the handshake is raced against that eviction and ends
at once, and the session loop has it as a branch of its select, so the place
opens as soon as the connection has actually gone and not on a timer tick.
The newcomer is not let in on a place still occupied; the controller retries
on its own with backoff, and by then the place is free. At most one
connection is ever on its way out, so a burst of refused arrivals clears no
more room than a single one, and the retry that takes the freed place counts
against its address's share: one address turns out at most as many
connections as it may hold.

One deadline, from the moment the connection starts, a branch of the session
loop's select rather than a check on the TestDelay tick: a connection not
authorized after 180s is closed, however alive it keeps itself, a wrong
password, a pending 2FA, an accept prompt or an admin-terminal credential
prompt left unanswered. The controller reconnects on its own and the prompt
comes back. It closes with the Timeout reason the idle path uses, and that
path still ends a connection that says nothing for 30s. There is no shorter
deadline for the first login request: an admin-terminal controller shows
its credential prompt before sending one, and a peer that wanted to dodge
such a deadline would only have to send a login request, so it would bound
nothing.

The peer address is normalized with try_into_v4 before admission, the same
form Connection::start keys the whitelist on, so an IPv4 peer and its
IPv4-mapped IPv6 form are one address and not two shares.

The WebRTC answerer's slot keeps bounding peer connection setup up to the
open data channel; from there this covers it like every other transport.

Tests cover the registry and the live bound: an address over its share is
refused while others are admitted; at the limit the newcomer is refused, the
oldest is told to go, nobody else is while it is on its way out, and its
place frees only when it has; an address at the limit turns out no more
connections than its share and is then refused without evicting anyone; and
with the limit held by 64 connections stalled in the handshake, one more
arrival is refused while the oldest handshake ends at once and only then is
there a place again.


Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:47:24 +08:00
rustdesk
4f56d6957a pin the webrtc crate to the fork that exposes max_binding_requests
The hbb_common bump before this one calls SettingEngine::set_ice_max_binding_requests(),
which the 0.13.0 release on crates.io does not have: only webrtc-util and webrtc-sctp
were patched to the fork, so the webrtc crate itself still came from the registry and
the build stopped at that call.

The three patches now point at the same fork revision, one commit past the one they
were on, which adds the setter. The webrtc crate depends on its siblings by path, so
patching it moves the rest of that workspace to the fork as well; the fork is upstream
v0.13.0 with changes to sctp and to this setter only, so those crates carry the same
code they did from the registry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
2026-09-16 14:18:03 +08:00
rustdesk
f312b57909 bump hbb_common: cap the answerer's ICE binding requests at 74
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
2026-09-16 13:28:32 +08:00
Joss Gray
f9c2f16857 fix: recover DXGI capture after access loss (#16024)
* fix: recover DXGI capture after access loss

* fix: track DXGI frame ownership

* fix: only recover DXGI access loss

* fix: stabilize DXGI recovery after mode switches

* fix: preserve DXGI recovery budget

* refactor: model DXGI frame lifecycle as enum

* docs: clarify DXGI frame cleanup order

* fix: scope DXGI frame grace to access loss recovery

* fix: preserve DXGI recovery across display changes

* chore: log DXGI recovery attempts at debug level
2026-09-16 13:18:44 +08:00
vipe3
8154c026a4 fix(android): default to entire screen for capture (#16163)
Use MediaProjectionConfig on Android 14+ to remove the app-versus-screen choice while retaining the legacy fallback.
2026-09-16 12:01:26 +08:00
fufesou
28269af9c7 fix(audio): preserve compatible playback and recover failed Windows outputs (#16150)
* fix(audio): preserve compatible playback on replacement failure

* fix(audio): use Windows events for capture worker wakeups

* fix(audio): recover Windows playback after output device failure

* reduce diffs

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(audio): reset decoder when retaining compatible playback

* fix(audio): integrate playback retention and Windows capture wakeups

* revert(audio): remove Windows capture-event notification changes

* docs(audio): explain Windows 7 capture limitations

* docs(audio): clarify capture changes introduced by #16095

* fix(audio): preserve playback through asynchronous Windows startup

Keep a compatible active output until the replacement callback confirms startup, preserving decoder progress on promotion or rollback. Handle superseding formats and simultaneous output failures without losing recovery state. Pin the scoped CPAL WASAPI event-ownership fix and add deterministic and native regressions.

* fix(audio): retain ready output across superseding formats

* fix(audio): update CPAL teardown recovery

Pin the upstream-aligned WASAPI cleanup with shared event lifetime, self-join prevention and fallible destructor diagnostics. Preserve the existing dependency graph and audio implementation.

* Use upstream-style CPAL stream teardown

* Refact: remove low value test

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(audio): retry playback when startup confirmation times out

Signed-off-by: fufesou <linlong1266@gmail.com>

* update cpal

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-16 11:55:53 +08:00
Maison da Silva
20e25e743d Fix Portuguese translations in ptbr.rs (#16234)
Fix Portuguese translations in ptbr.rs
2026-09-16 11:00:49 +08:00
RustDesk
851d2df88c fix(audio): three 100% CPU busy loops on the _pa path (#16229)
The Linux audio service in `--server` ignored the `Err` from `next_raw()`, so
once the cm-side `_pa` peer closed, every iteration re-polled a dead socket:
tokio-util's paused `Framed` issues one 0-byte read per poll and returns ready
at once, never `Pending`. The thread never parked and burned a full core for
the life of the process. Propagate instead, so `ServiceTmpl::run`'s existing
backoff ends the inner loop and reconnects.

Two sibling loops on the same audio path have the same shape:

- `ipc::start_pa` (runs in `--cm`) ignored the `Err` from
  `psimple::Simple::read`, so a dead pulse handle spins there instead.
- `start_voice_call`'s forwarding thread polls two channels with `try_recv`
  and has no blocking primitive at all: measured 99.8% of a core for the whole
  call, against 1.0% with a 1 ms pause (audio packets arrive every 10 ms).

fix https://github.com/rustdesk/rustdesk/issues/16226

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 17:35:40 +08:00
solokot
be757de462 Update ru.rs (#16217) 2026-09-15 16:14:49 +08:00
fufesou
a437eb9bc6 fix(windows): recognize Hyper-V Enhanced Sessions by protocol (#16223)
Fixes #16182

Use WTSClientProtocolType to identify RDP sessions with nonstandard
names during session selection and enumeration.

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-15 15:21:19 +08:00
21pages
39d4f1854b chore(qos): remove verbose diagnostics while retaining trace logs (#16214) 2026-09-14 17:19:58 +08:00
VenusGirl❤
bd028ad350 Update Korean (#16212) 2026-09-14 16:15:55 +08:00
bovirus
b48fe17b52 Update Italian language (#16211)
* Update it.rs

* Update it.rs
2026-09-14 16:15:31 +08:00
26 changed files with 1346 additions and 273 deletions

45
Cargo.lock generated
View File

@@ -1717,7 +1717,7 @@ dependencies = [
[[package]]
name = "cpal"
version = "0.15.3"
source = "git+https://github.com/rustdesk-org/cpal?branch=osx-screencapturekit#69ad2578adc9200093fc81cdfbdad63dbc4274f9"
source = "git+https://github.com/rustdesk-org/cpal?branch=osx-screencapturekit#96d4da121b7d949677ac5b6887413a9185fd7f39"
dependencies = [
"alsa",
"cidre",
@@ -4121,8 +4121,7 @@ dependencies = [
[[package]]
name = "interceptor"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac0781c825d602095113772e389ef0607afcb869ae0e68a590d8e0799cdcef8"
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
dependencies = [
"async-trait",
"bytes",
@@ -7008,8 +7007,7 @@ dependencies = [
[[package]]
name = "rtcp"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9689528bf3a9eb311fd938d05516dd546412f9ce4fffc8acfc1db27cc3dbf72"
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
dependencies = [
"bytes",
"thiserror 1.0.61",
@@ -7019,8 +7017,7 @@ dependencies = [
[[package]]
name = "rtp"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c54733451a67d76caf9caa07a7a2cec6871ea9dda92a7847f98063d459200f4b"
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
dependencies = [
"bytes",
"memchr",
@@ -7465,8 +7462,7 @@ dependencies = [
[[package]]
name = "sdp"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd277015eada44a0bb810a4b84d3bf6e810573fa62fb442f457edf6a1087a69"
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
dependencies = [
"rand 0.8.5",
"substring",
@@ -8038,8 +8034,7 @@ dependencies = [
[[package]]
name = "stun"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dbc2bab375524093c143dc362a03fb6a1fb79e938391cdb21665688f88a088a"
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
dependencies = [
"base64 0.22.1",
"crc",
@@ -8921,8 +8916,7 @@ dependencies = [
[[package]]
name = "turn"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f5aea1116456e1da71c45586b87c72e3b43164fbf435eb93ff6aa475416a9a4"
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -9585,8 +9579,7 @@ dependencies = [
[[package]]
name = "webrtc"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24bab7195998d605c862772f90a452ba655b90a2f463c850ac032038890e367a"
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
dependencies = [
"arc-swap",
"async-trait",
@@ -9629,8 +9622,7 @@ dependencies = [
[[package]]
name = "webrtc-data"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e97b932854da633a767eff0cc805425a2222fc6481e96f463e57b015d949d1d"
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
dependencies = [
"bytes",
"log",
@@ -9644,8 +9636,7 @@ dependencies = [
[[package]]
name = "webrtc-dtls"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ccbe4d9049390ab52695c3646c1395c877e16c15fb05d3bda8eee0c7351711c"
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
dependencies = [
"aes",
"aes-gcm",
@@ -9681,8 +9672,7 @@ dependencies = [
[[package]]
name = "webrtc-ice"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb51bde0d790f109a15bfe4d04f1b56fb51d567da231643cb3f21bb74d678997"
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
dependencies = [
"arc-swap",
"async-trait",
@@ -9706,8 +9696,7 @@ dependencies = [
[[package]]
name = "webrtc-mdns"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "979cc85259c53b7b620803509d10d35e2546fa505d228850cbe3f08765ea6ea8"
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
dependencies = [
"log",
"socket2 0.5.10",
@@ -9719,8 +9708,7 @@ dependencies = [
[[package]]
name = "webrtc-media"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80041211deccda758a3e19aa93d6b10bc1d37c9183b519054b40a83691d13810"
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
dependencies = [
"byteorder",
"bytes",
@@ -9732,7 +9720,7 @@ dependencies = [
[[package]]
name = "webrtc-sctp"
version = "0.12.0"
source = "git+https://github.com/rustdesk-org/webrtc?rev=db3b07a9dd8f195916c89c2e62a8911402b11d27#db3b07a9dd8f195916c89c2e62a8911402b11d27"
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
dependencies = [
"arc-swap",
"async-trait",
@@ -9749,8 +9737,7 @@ dependencies = [
[[package]]
name = "webrtc-srtp"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01e773f79b09b057ffbda6b03fe7b43403b012a240cf8d05d630674c3723b5bb"
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
dependencies = [
"aead",
"aes",
@@ -9772,7 +9759,7 @@ dependencies = [
[[package]]
name = "webrtc-util"
version = "0.11.0"
source = "git+https://github.com/rustdesk-org/webrtc?rev=db3b07a9dd8f195916c89c2e62a8911402b11d27#db3b07a9dd8f195916c89c2e62a8911402b11d27"
source = "git+https://github.com/rustdesk-org/webrtc?rev=80d5a20532cf58f5d4d237c437a98ceb85ee40dc#80d5a20532cf58f5d4d237c437a98ceb85ee40dc"
dependencies = [
"async-trait",
"bitflags 1.3.2",

View File

@@ -231,8 +231,11 @@ libxdo-sys = { path = "libs/libxdo-sys-stub" }
# the SACK settle the rest (F-RTO), timed from the latest send, so a stall no longer resends the
# whole backlog behind itself while a short lost tail still comes back at once.
# Pinned by rev, not branch: a fork branch can be rewritten out from under the lockfile.
webrtc-util = { git = "https://github.com/rustdesk-org/webrtc", rev = "db3b07a9dd8f195916c89c2e62a8911402b11d27" }
webrtc-sctp = { git = "https://github.com/rustdesk-org/webrtc", rev = "db3b07a9dd8f195916c89c2e62a8911402b11d27" }
# webrtc: SettingEngine cannot reach the ICE agent's max_binding_requests, which decides how
# long the answerer keeps checking a pair that has not answered yet.
webrtc = { git = "https://github.com/rustdesk-org/webrtc", rev = "80d5a20532cf58f5d4d237c437a98ceb85ee40dc" }
webrtc-util = { git = "https://github.com/rustdesk-org/webrtc", rev = "80d5a20532cf58f5d4d237c437a98ceb85ee40dc" }
webrtc-sctp = { git = "https://github.com/rustdesk-org/webrtc", rev = "80d5a20532cf58f5d4d237c437a98ceb85ee40dc" }
[package.metadata.winres]
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."

View File

@@ -2,6 +2,7 @@ package com.carriez.flutter_hbb
import android.app.Activity
import android.content.Intent
import android.media.projection.MediaProjectionConfig
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Bundle
@@ -19,7 +20,13 @@ class PermissionRequestTransparentActivity: Activity() {
ACT_REQUEST_MEDIA_PROJECTION -> {
val mediaProjectionManager =
getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val intent = mediaProjectionManager.createScreenCaptureIntent()
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
mediaProjectionManager.createScreenCaptureIntent(
MediaProjectionConfig.createConfigForDefaultDisplay()
)
} else {
mediaProjectionManager.createScreenCaptureIntent()
}
startActivityForResult(intent, REQ_REQUEST_MEDIA_PROJECTION)
}
else -> finish()

View File

@@ -41,6 +41,13 @@ impl<T> Drop for ComPtr<T> {
}
}
#[derive(Clone, Copy, PartialEq)]
enum FrameState {
Idle,
Acquired,
Mapped,
}
pub struct Capturer {
device: ComPtr<ID3D11Device>,
display: Display,
@@ -58,6 +65,7 @@ pub struct Capturer {
output_texture: bool,
adapter_desc1: DXGI_ADAPTER_DESC1,
rotate: Rotate,
frame_state: FrameState,
}
impl Capturer {
@@ -174,6 +182,7 @@ impl Capturer {
output_texture: false,
adapter_desc1,
rotate,
frame_state: FrameState::Idle,
})
}
@@ -335,6 +344,7 @@ impl Capturer {
let mut info = mem::MaybeUninit::uninit().assume_init();
wrap_hresult((*self.duplication.0).AcquireNextFrame(timeout, &mut info, &mut frame))?;
self.frame_state = FrameState::Acquired;
let frame = ComPtr(frame);
if *info.LastPresentTime.QuadPart() == 0 {
@@ -345,9 +355,11 @@ impl Capturer {
let mut rect = mem::MaybeUninit::uninit().assume_init();
if self.fastlane {
wrap_hresult((*self.duplication.0).MapDesktopSurface(&mut rect))?;
self.frame_state = FrameState::Mapped;
} else {
self.surface = ComPtr(self.ohgodwhat(frame.0)?);
wrap_hresult((*self.surface.0).Map(&mut rect, DXGI_MAP_READ))?;
self.frame_state = FrameState::Mapped;
}
Ok((rect.pBits, rect.Pitch))
}
@@ -424,7 +436,7 @@ impl Capturer {
}
}
} else {
self.unmap();
self.release_frame()?;
let r = self.load_frame(timeout)?;
let rotate = match self.display.rotation() {
DXGI_MODE_ROTATION_IDENTITY | DXGI_MODE_ROTATION_UNSPECIFIED => kRotate0,
@@ -472,12 +484,13 @@ impl Capturer {
if self.duplication.0.is_null() {
return Err(std::io::ErrorKind::AddrNotAvailable.into());
}
(*self.duplication.0).ReleaseFrame();
self.release_frame()?;
let mut frame = ptr::null_mut();
#[allow(invalid_value)]
let mut info = mem::MaybeUninit::uninit().assume_init();
wrap_hresult((*self.duplication.0).AcquireNextFrame(timeout, &mut info, &mut frame))?;
self.frame_state = FrameState::Acquired;
let frame = ComPtr(frame);
if info.AccumulatedFrames == 0 || *info.LastPresentTime.QuadPart() == 0 {
@@ -574,16 +587,42 @@ impl Capturer {
}
}
fn unmap(&self) {
fn release_frame(&mut self) -> io::Result<()> {
if self.duplication.is_null() {
return Ok(());
}
let mut first_error = None;
// Unmap before ReleaseFrame invalidates the desktop surface; use the same
// order for staging surfaces. Cleanup advances Mapped -> Acquired -> Idle,
// while Idle is a no-op. Advance state even on errors to avoid retrying
// cleanup, but still attempt ReleaseFrame if unmapping fails.
unsafe {
(*self.duplication.0).ReleaseFrame();
if self.fastlane {
(*self.duplication.0).UnMapDesktopSurface();
} else {
if !self.surface.is_null() {
(*self.surface.0).Unmap();
if self.frame_state == FrameState::Mapped {
let result = if self.fastlane {
wrap_hresult((*self.duplication.0).UnMapDesktopSurface())
} else if !self.surface.is_null() {
wrap_hresult((*self.surface.0).Unmap())
} else {
Ok(())
};
self.frame_state = FrameState::Acquired;
if let Err(err) = result {
first_error = Some(err);
}
}
if self.frame_state == FrameState::Acquired {
let result = wrap_hresult((*self.duplication.0).ReleaseFrame());
self.frame_state = FrameState::Idle;
if first_error.is_none() {
if let Err(err) = result {
first_error = Some(err);
}
}
}
}
match first_error {
Some(err) => Err(err),
None => Ok(()),
}
}
@@ -599,8 +638,8 @@ impl Capturer {
impl Drop for Capturer {
fn drop(&mut self) {
if !self.duplication.is_null() {
self.unmap();
if let Err(err) = self.release_frame() {
eprintln!("DXGI frame cleanup failed: {err}");
}
}
}

View File

@@ -30,7 +30,8 @@ use uuid::Uuid;
use crate::{
check_port,
common::input::{MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT, MOUSE_TYPE_DOWN, MOUSE_TYPE_UP},
create_symmetric_key_msg, decode_id_pk, decode_id_pk_dtls, get_rs_pk, is_keyboard_mode_supported,
create_symmetric_key_msg, decode_id_pk, decode_id_pk_dtls, dtls_fingerprint_bound, get_rs_pk,
is_keyboard_mode_supported,
kcp_stream::KcpStream,
secure_tcp,
ui_interface::{get_builtin_option, resolve_avatar_url, use_texture_render},
@@ -95,7 +96,10 @@ pub use super::lang::*;
#[cfg(not(target_os = "linux"))]
mod audio_playback;
#[cfg(target_os = "windows")]
mod audio_playback_recovery;
#[cfg(all(test, not(target_os = "linux")))]
#[path = "client/tests/audio_state_tests.rs"]
mod audio_state_tests;
pub mod file_trait;
pub mod helper;
@@ -1668,7 +1672,7 @@ impl Client {
let actual_fp = conn.dtls_fingerprint(false).await.ok_or_else(
|| anyhow!("WebRTC DTLS fingerprint unavailable"),
)?;
if signed_fp.is_empty() || signed_fp != actual_fp {
if !dtls_fingerprint_bound(&signed_fp, &actual_fp) {
bail!("WebRTC DTLS fingerprint not bound to peer identity (possible MITM)");
}
}
@@ -2083,6 +2087,8 @@ pub struct AudioHandler {
device_channel: u16,
#[cfg(not(target_os = "linux"))]
playback_status: Arc<audio_playback::AudioPlaybackStatus>,
#[cfg(target_os = "windows")]
playback_recovery: audio_playback_recovery::PlaybackRecovery,
}
#[cfg(not(target_os = "linux"))]
@@ -2390,22 +2396,53 @@ impl AudioHandler {
/// Handle audio format and create an audio decoder.
pub fn handle_format(&mut self, f: AudioFormat) {
self.handle_format_with_start(f, Self::start_audio);
}
fn handle_format_with_start(
&mut self,
f: AudioFormat,
start: impl FnOnce(&mut Self, AudioFormat) -> ResultType<()>,
) {
if !is_supported_audio_channel_count(f.channels) {
log::error!("Unsupported audio channel count: {}", f.channels);
return;
}
match AudioDecoder::new(f.sample_rate, if f.channels > 1 { Stereo } else { Mono }) {
Ok(d) => {
#[cfg(target_os = "windows")]
let playback_failed = self.cancel_pending_playback();
#[cfg(target_os = "linux")]
let keep_existing_stream = self.simple.is_some()
&& self.sample_rate.0 == f.sample_rate
&& u32::from(self.channels) == f.channels;
#[cfg(not(target_os = "linux"))]
let keep_existing_stream = false;
let keep_existing_stream = self.audio_stream.is_some()
&& self.sample_rate.0 == f.sample_rate
&& u32::from(self.channels) == f.channels;
let buffer = vec![0.; f.sample_rate as usize * f.channels as usize];
#[cfg(not(target_os = "linux"))]
let mut previous = std::mem::take(self);
#[cfg(target_os = "windows")]
self.prepare_playback(&f);
self.audio_decoder = Some((d, buffer));
self.channels = f.channels as _;
let result = self.start_audio(f);
let result = start(self, f);
#[cfg(target_os = "windows")]
let keep_existing_stream = keep_existing_stream
&& !playback_failed
&& !previous.playback_recovery.report_pending();
#[cfg(not(target_os = "linux"))]
if result.is_err() && keep_existing_stream {
// The restarted capture has new Opus history even when output startup fails.
previous.audio_decoder = self.audio_decoder.take();
*self = previous;
self.handle_audio_start_result(result, true);
return;
}
#[cfg(target_os = "windows")]
self.finish_playback_replacement(result, keep_existing_stream.then_some(previous));
#[cfg(not(target_os = "windows"))]
self.handle_audio_start_result(result, keep_existing_stream);
}
Err(err) => {
@@ -2493,6 +2530,9 @@ impl AudioHandler {
device: &Device,
) -> ResultType<()> {
self.device_channel = config.channels;
#[cfg(target_os = "windows")]
let err_fn = self.playback_recovery.new_error_callback();
#[cfg(not(target_os = "windows"))]
let err_fn = move |err| {
// too many errors, will improve later
log::trace!("an error occurred on stream: {}", err);
@@ -4065,7 +4105,11 @@ pub fn start_audio_thread() -> MediaSender {
std::thread::spawn(move || {
let mut audio_handler = AudioHandler::default();
loop {
if let Ok(data) = audio_receiver.recv() {
#[cfg(target_os = "windows")]
let received = audio_handler.receive_audio(&audio_receiver);
#[cfg(not(target_os = "windows"))]
let received = audio_receiver.recv();
if let Ok(data) = received {
match data {
MediaData::AudioFrame(af) => {
audio_handler.handle_frame(*af);

View File

@@ -0,0 +1,184 @@
use super::{AudioDecoder, AudioFormat, AudioHandler, MediaData, Mono, Stereo};
use cpal::StreamError;
use crossbeam_queue::SegQueue;
use hbb_common::{log, tokio::time::Instant, ResultType};
use std::{
sync::{atomic::Ordering, mpsc, Arc},
time::Duration,
};
const RECOVERY_INTERVAL: Duration = Duration::from_secs(1);
pub(super) const STARTUP_CONFIRMATION_TIMEOUT: Duration = Duration::from_secs(5);
// The pinned WASAPI backend reports this warning but keeps its worker running.
const PRIORITY_WARNING_PREFIX: &str = "SetThreadPriority failed: ";
#[path = "audio_playback_startup.rs"]
mod startup;
#[derive(Default)]
pub(super) struct PlaybackRecovery {
pub(super) errors: Arc<SegQueue<StreamError>>,
format: Option<AudioFormat>,
pub(super) retry_at: Option<Instant>,
restart_not_before: Option<Instant>,
awaiting_callback: bool,
startup_deadline: Option<Instant>,
pending_output: Option<Box<AudioHandler>>,
}
impl PlaybackRecovery {
pub(super) fn new_error_callback(&mut self) -> impl FnMut(StreamError) + Send + 'static {
self.errors = Default::default();
let errors = self.errors.clone();
move |error| errors.push(error)
}
pub(super) fn report_pending(&self) -> bool {
let mut failed = false;
while let Some(error) = self.errors.pop() {
if matches!(&error, StreamError::BackendSpecific { err }
if err.description.starts_with(PRIORITY_WARNING_PREFIX))
{
log::warn!("Audio playback nonterminal priority warning: {error}");
} else {
log::error!("Audio playback stream failed: {error}");
failed = true;
}
}
failed
}
}
impl AudioHandler {
fn clear_playback_stream(&mut self) {
// Dropping CPAL may join its worker; run this on the owner, not its callback.
self.audio_stream = None;
self.playback_recovery.report_pending();
self.playback_status.report_errors();
let recovery = std::mem::take(self).playback_recovery;
self.playback_recovery.format = recovery.format;
self.playback_recovery.retry_at = recovery.retry_at;
self.playback_recovery.restart_not_before = recovery.restart_not_before;
}
pub(super) fn prepare_playback(&mut self, format: &AudioFormat) {
self.clear_playback_stream();
self.playback_recovery.format = Some(format.clone());
self.playback_recovery.retry_at = None;
self.playback_recovery.restart_not_before = None;
}
pub(super) fn finish_playback_start(&mut self, result: ResultType<()>) {
let now = Instant::now();
let retry_at = now + RECOVERY_INTERVAL;
self.playback_recovery.restart_not_before = Some(retry_at);
match result {
Ok(()) => {
self.playback_recovery.retry_at = None;
self.playback_recovery.awaiting_callback = true;
self.playback_recovery.startup_deadline = Some(now + STARTUP_CONFIRMATION_TIMEOUT);
log::info!("Audio playback stream opened; waiting for output callback");
}
Err(error) => {
self.clear_playback_stream();
self.playback_recovery.retry_at = Some(retry_at);
log::error!(
"Audio playback start failed: {error:#}; retrying in {RECOVERY_INTERVAL:?}"
);
}
}
}
fn playback_start_timed_out(&mut self, now: Instant) -> bool {
if !self.playback_recovery.awaiting_callback
|| self.playback_status.ready.load(Ordering::Acquire)
|| !self
.playback_recovery
.startup_deadline
.is_some_and(|due| now >= due)
{
return false;
}
self.playback_recovery.awaiting_callback = false;
self.playback_recovery.startup_deadline = None;
log::error!("Audio playback start timed out waiting for output callback");
true
}
fn restart_playback(&mut self, format: AudioFormat) -> ResultType<()> {
let channels = if format.channels > 1 { Stereo } else { Mono };
let decoder = AudioDecoder::new(format.sample_rate, channels)?;
let buffer = vec![0.; format.sample_rate as usize * format.channels as usize];
let channel_count = format.channels as _;
self.start_audio(format)?;
self.channels = channel_count;
self.audio_decoder = Some((decoder, buffer));
Ok(())
}
pub(super) fn recover_playback_with(
&mut self,
now: Instant,
restart: impl FnOnce(&mut Self, AudioFormat) -> ResultType<()>,
) {
let failed = self.resolve_pending_playback(now).unwrap_or_else(|| {
self.playback_recovery.report_pending() || self.playback_start_timed_out(now)
});
if failed {
self.clear_playback_stream();
self.playback_recovery.retry_at = Some(
self.playback_recovery
.restart_not_before
.map_or(now, |due| due.max(now)),
);
}
if self.playback_recovery.awaiting_callback
&& self.playback_status.ready.load(Ordering::Acquire)
{
self.playback_recovery.awaiting_callback = false;
self.playback_recovery.startup_deadline = None;
log::info!("Audio playback output callback started");
}
if !self
.playback_recovery
.retry_at
.is_some_and(|due| now >= due)
{
return;
}
let Some(format) = self.playback_recovery.format.clone() else {
return;
};
log::info!("Recreating audio playback on the current default output device");
let result = restart(self, format);
self.finish_playback_start(result);
}
pub(super) fn receive_audio(
&mut self,
receiver: &mpsc::Receiver<MediaData>,
) -> Result<MediaData, mpsc::RecvError> {
receive_with_recovery(receiver, RECOVERY_INTERVAL, || {
self.recover_playback_with(Instant::now(), Self::restart_playback);
})
}
}
pub(super) fn receive_with_recovery(
receiver: &mpsc::Receiver<MediaData>,
interval: Duration,
mut recover: impl FnMut(),
) -> Result<MediaData, mpsc::RecvError> {
loop {
match receiver.recv_timeout(interval) {
Ok(data) => {
if !matches!(data, MediaData::AudioFormat(_)) {
recover();
}
return Ok(data);
}
Err(mpsc::RecvTimeoutError::Timeout) => recover(),
Err(mpsc::RecvTimeoutError::Disconnected) => return Err(mpsc::RecvError),
}
}
}

View File

@@ -0,0 +1,63 @@
use super::{AudioHandler, Instant, Ordering, ResultType};
use hbb_common::log;
impl AudioHandler {
pub(in crate::client) fn cancel_pending_playback(&mut self) -> bool {
// Format messages bypass recovery; retain a usable candidate before superseding it.
let failed = self
.resolve_pending_playback(Instant::now())
.unwrap_or(false);
if let Some(mut pending) = self.playback_recovery.pending_output.take() {
pending.audio_stream = None;
pending.playback_recovery.report_pending();
pending.playback_status.report_errors();
}
failed
}
pub(in crate::client) fn finish_playback_replacement(
&mut self,
result: ResultType<()>,
previous: Option<Self>,
) {
self.finish_playback_start(result);
let Some(mut previous) = previous else {
return;
};
previous.audio_decoder = self.audio_decoder.take();
let candidate = std::mem::replace(self, previous);
self.playback_recovery.pending_output = Some(Box::new(candidate));
log::info!("Audio playback replacement pending; continuing on the compatible output");
self.recover_playback_with(Instant::now(), Self::restart_playback);
}
pub(super) fn resolve_pending_playback(&mut self, now: Instant) -> Option<bool> {
let mut candidate = self.playback_recovery.pending_output.take()?;
let candidate_failed =
candidate.playback_recovery.report_pending() || candidate.playback_start_timed_out(now);
let previous_failed =
self.playback_recovery.report_pending() || self.playback_start_timed_out(now);
if candidate_failed {
self.playback_recovery.restart_not_before =
candidate.playback_recovery.restart_not_before;
candidate.audio_stream = None;
candidate.playback_recovery.report_pending();
candidate.playback_status.report_errors();
if !previous_failed {
log::error!("Audio playback replacement failed before startup confirmation; keeping the existing compatible stream");
}
return Some(previous_failed);
}
if candidate.playback_status.ready.load(Ordering::Acquire) || previous_failed {
candidate.audio_decoder = self.audio_decoder.take();
self.audio_stream = None;
self.playback_recovery.report_pending();
self.playback_status.report_errors();
*self = *candidate;
return Some(false);
}
self.playback_recovery.pending_output = Some(candidate);
// A second active-queue read could discard a healthy pending candidate.
Some(false)
}
}

View File

@@ -1,113 +0,0 @@
use super::{create_audio_resampler, AudioDecoder, AudioFrame, AudioHandler, Stereo};
use cpal::traits::StreamTrait;
use hbb_common::anyhow::anyhow;
use magnum_opus::{Application::LowDelay, Encoder};
use ringbuf::{ring_buffer::RbBase, Rb};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
const INPUT_RATE: u32 = 24_000;
const OUTPUT_RATE: u32 = 48_000;
const CHANNELS: u16 = 2;
const PACKETS_PER_SECOND: usize = 100;
const MAX_PACKET_BYTES: usize = 4_096;
const SAMPLE_VALUE: f32 = 0.25;
struct TrackedAudioStream(Arc<AtomicBool>);
impl StreamTrait for TrackedAudioStream {
fn play(&self) -> Result<(), cpal::PlayStreamError> {
Ok(())
}
fn pause(&self) -> Result<(), cpal::PauseStreamError> {
Ok(())
}
}
impl Drop for TrackedAudioStream {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
fn decoder(sample_rate: u32) -> (AudioDecoder, Vec<f32>) {
(
AudioDecoder::new(sample_rate, Stereo).unwrap(),
vec![0.0; sample_rate as usize * CHANNELS as usize],
)
}
fn active_handler(input_rate: u32) -> (AudioHandler, Arc<AtomicBool>) {
let dropped = Arc::new(AtomicBool::new(false));
let handler = AudioHandler {
audio_decoder: Some(decoder(input_rate)),
audio_resampler: create_audio_resampler(input_rate, OUTPUT_RATE, CHANNELS).unwrap(),
sample_rate: (input_rate, OUTPUT_RATE),
audio_stream: Some(Box::new(TrackedAudioStream(dropped.clone()))),
channels: CHANNELS,
device_channel: CHANNELS,
..Default::default()
};
handler.playback_status.ready.store(true, Ordering::Release);
(handler, dropped)
}
fn audio_frame() -> AudioFrame {
let samples = OUTPUT_RATE as usize / PACKETS_PER_SECOND * CHANNELS as usize;
let mut encoder = Encoder::new(OUTPUT_RATE, Stereo, LowDelay).unwrap();
AudioFrame {
data: encoder
.encode_vec_float(&vec![SAMPLE_VALUE; samples], MAX_PACKET_BYTES)
.unwrap()
.into(),
..Default::default()
}
}
#[test]
fn failed_format_change_discards_old_playback_state() {
let (mut handler, dropped) = active_handler(INPUT_RATE);
handler
.audio_buffer
.0
.lock()
.unwrap()
.push_slice(&[SAMPLE_VALUE; CHANNELS as usize]);
handler.audio_decoder = Some(decoder(OUTPUT_RATE));
handler.sample_rate = (OUTPUT_RATE, OUTPUT_RATE);
handler.handle_audio_start_result(
Err(anyhow!("Injected output stream startup failure")),
false,
);
assert!(dropped.load(Ordering::SeqCst));
assert!(handler.audio_stream.is_none());
assert!(handler.audio_resampler.is_none());
assert!(handler.audio_decoder.is_none());
assert!(!handler.playback_status.ready.load(Ordering::Acquire));
handler.handle_frame(audio_frame());
assert_eq!(handler.audio_buffer.0.lock().unwrap().occupied_len(), 0);
}
#[test]
fn successful_start_or_compatible_failure_preserves_audio_packet_duration() {
for result in [
Ok(()),
Err(anyhow!("Injected compatible stream replacement failure")),
] {
let (mut handler, dropped) = active_handler(OUTPUT_RATE);
handler.handle_audio_start_result(result, true);
handler.handle_frame(audio_frame());
assert!(!dropped.load(Ordering::SeqCst));
assert_eq!(
handler.audio_buffer.0.lock().unwrap().occupied_len(),
OUTPUT_RATE as usize / PACKETS_PER_SECOND * CHANNELS as usize
);
}
}

View File

@@ -598,6 +598,9 @@ impl<T: InvokeUiSession> Remote<T> {
} else {
log::debug!("Failed to record local audio channel: {}", err);
}
// Both arms fall through with nothing else in this loop blocking, so
// without a pause the thread spun a core for the whole voice call.
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
}

View File

@@ -0,0 +1,226 @@
use super::*;
use crate::client::{
audio_playback::AudioPlaybackStatus, audio_playback_recovery::STARTUP_CONFIRMATION_TIMEOUT,
};
use cpal::StreamError;
use crossbeam_queue::SegQueue;
use hbb_common::tokio::time::Instant;
use std::time::Duration;
const AFTER_COOLDOWN: Duration = Duration::from_secs(2);
type PendingOutput = (
Arc<AtomicBool>,
Arc<AudioPlaybackStatus>,
Arc<SegQueue<StreamError>>,
);
fn install_output(handler: &mut AudioHandler, dropped: Arc<AtomicBool>) {
handler.sample_rate = (INPUT_RATE, OUTPUT_RATE);
handler.device_channel = CHANNELS;
handler.audio_stream = Some(Box::new(TrackedAudioStream(dropped)));
handler.playback_status.ready.store(true, Ordering::Release);
}
fn recovery_handler() -> (AudioHandler, Arc<AtomicBool>) {
let dropped = Arc::new(AtomicBool::new(false));
let mut handler = AudioHandler::default();
handler.handle_format_with_start(format(INPUT_RATE, CHANNELS), |candidate, _| {
install_output(candidate, dropped.clone());
Ok(())
});
(handler, dropped)
}
fn begin_pending(handler: &mut AudioHandler) -> PendingOutput {
let dropped = Arc::new(AtomicBool::new(false));
let mut state = None;
handler.handle_format_with_start(format(INPUT_RATE, CHANNELS), |candidate, _| {
install_output(candidate, dropped.clone());
candidate
.playback_status
.ready
.store(false, Ordering::Release);
state = Some((
candidate.playback_status.clone(),
candidate.playback_recovery.errors.clone(),
));
Ok(())
});
let (status, errors) = state.unwrap();
(dropped, status, errors)
}
#[test]
fn unconfirmed_start_retries_without_callback_or_error() {
let dropped = Arc::new(AtomicBool::new(false));
let mut handler = AudioHandler::default();
handler.handle_format_with_start(format(INPUT_RATE, CHANNELS), |candidate, _| {
install_output(candidate, dropped.clone());
candidate
.playback_status
.ready
.store(false, Ordering::Release);
Ok(())
});
handler.recover_playback_with(Instant::now(), |_, _| {
panic!("Startup confirmation deadline has not elapsed")
});
assert!(!dropped.load(Ordering::SeqCst));
let expired = Instant::now() + STARTUP_CONFIRMATION_TIMEOUT;
let mut attempts = 0;
handler.recover_playback_with(expired, |candidate, requested| {
attempts += 1;
assert!(dropped.load(Ordering::SeqCst));
assert_eq!(requested, format(INPUT_RATE, CHANNELS));
install_output(candidate, Arc::new(AtomicBool::new(false)));
Ok(())
});
assert_eq!(attempts, 1);
handler.recover_playback_with(expired + STARTUP_CONFIRMATION_TIMEOUT, |_, _| {
panic!("Confirmed output must not be reopened")
});
}
#[test]
fn already_terminal_candidate_cannot_replace_compatible_output() {
let (mut handler, dropped) = recovery_handler();
let candidate_dropped = Arc::new(AtomicBool::new(false));
let old_buffer = handler.audio_buffer.0.clone();
handler.handle_format_with_start(format(INPUT_RATE, CHANNELS), |candidate, _| {
install_output(candidate, candidate_dropped.clone());
candidate
.playback_recovery
.errors
.push(StreamError::DeviceNotAvailable);
Ok(())
});
assert!(!dropped.load(Ordering::SeqCst));
assert!(candidate_dropped.load(Ordering::SeqCst));
assert!(Arc::ptr_eq(&old_buffer, &handler.audio_buffer.0));
handler.recover_playback_with(Instant::now() + AFTER_COOLDOWN, |_, _| {
panic!("Compatible active output must not be reopened")
});
}
#[test]
fn pending_output_keeps_playing_and_transfers_decoder_history_on_commit() {
let (mut handler, old_dropped) = recovery_handler();
let old_buffer = handler.audio_buffer.0.clone();
let (_, status, _) = begin_pending(&mut handler);
let frame = audio_frame();
let (mut reference, mut expected) = decoder(INPUT_RATE);
reference
.decode_float(&frame.data, &mut expected, false)
.unwrap();
handler.handle_frame(frame.clone());
assert!(Arc::ptr_eq(&old_buffer, &handler.audio_buffer.0));
assert!(!drain_audio(&handler).is_empty());
status.ready.store(true, Ordering::Release);
handler.recover_playback_with(Instant::now(), |_, _| panic!("Candidate already exists"));
assert!(old_dropped.load(Ordering::SeqCst));
assert!(Arc::ptr_eq(&status, &handler.playback_status));
let samples = reference
.decode_float(&frame.data, &mut expected, false)
.unwrap()
* CHANNELS as usize;
handler.handle_frame(frame);
assert_eq!(
&handler.audio_decoder.as_ref().unwrap().1[..samples],
&expected[..samples]
);
}
#[test]
fn rollback_keeps_the_restarted_decoders_accumulated_history() {
let (mut handler, old_dropped) = recovery_handler();
let frame = audio_frame();
handler.handle_frame(frame.clone());
let (candidate_dropped, _, errors) = begin_pending(&mut handler);
let (mut reference, mut expected) = decoder(INPUT_RATE);
handler.handle_frame(frame.clone());
reference
.decode_float(&frame.data, &mut expected, false)
.unwrap();
errors.push(StreamError::DeviceNotAvailable);
handler.recover_playback_with(Instant::now(), |_, _| panic!("Old output still works"));
let samples = reference
.decode_float(&frame.data, &mut expected, false)
.unwrap()
* CHANNELS as usize;
handler.handle_frame(frame);
assert!(!old_dropped.load(Ordering::SeqCst));
assert!(candidate_dropped.load(Ordering::SeqCst));
assert_eq!(
&handler.audio_decoder.as_ref().unwrap().1[..samples],
&expected[..samples]
);
}
#[test]
fn later_compatible_format_retires_only_the_pending_attempt() {
let (mut handler, old_dropped) = recovery_handler();
let (first_dropped, _, first_errors) = begin_pending(&mut handler);
let (second_dropped, second_status, _) = begin_pending(&mut handler);
assert!(first_dropped.load(Ordering::SeqCst));
assert!(!old_dropped.load(Ordering::SeqCst));
first_errors.push(StreamError::DeviceNotAvailable);
second_status.ready.store(true, Ordering::Release);
handler.recover_playback_with(Instant::now(), |_, _| {
panic!("Retired attempt affected current output")
});
assert!(old_dropped.load(Ordering::SeqCst));
assert!(!second_dropped.load(Ordering::SeqCst));
assert!(Arc::ptr_eq(&second_status, &handler.playback_status));
}
#[test]
fn both_outputs_failing_retains_format_and_paces_recovery() {
let (mut handler, old_dropped) = recovery_handler();
let old_errors = handler.playback_recovery.errors.clone();
let (candidate_dropped, _, errors) = begin_pending(&mut handler);
old_errors.push(StreamError::DeviceNotAvailable);
errors.push(StreamError::DeviceNotAvailable);
handler.recover_playback_with(Instant::now(), |_, _| panic!("Retry must be paced"));
assert!(old_dropped.load(Ordering::SeqCst));
assert!(candidate_dropped.load(Ordering::SeqCst));
assert!(handler.audio_stream.is_none());
let due = handler.playback_recovery.retry_at.unwrap();
let mut attempts = 0;
handler.recover_playback_with(due, |_, requested| {
attempts += 1;
assert_eq!(requested, format(INPUT_RATE, CHANNELS));
Ok(())
});
assert_eq!(attempts, 1);
}
#[test]
fn superseding_format_keeps_ready_candidate_when_active_output_failed() {
let (mut handler, old_dropped) = recovery_handler();
let old_errors = handler.playback_recovery.errors.clone();
let (candidate_dropped, candidate_status, _) = begin_pending(&mut handler);
candidate_status.ready.store(true, Ordering::Release);
old_errors.push(StreamError::DeviceNotAvailable);
handler.handle_format_with_start(format(INPUT_RATE, CHANNELS), failed_output);
assert!(!candidate_dropped.load(Ordering::SeqCst));
assert!(old_dropped.load(Ordering::SeqCst));
assert!(Arc::ptr_eq(&candidate_status, &handler.playback_status));
assert!(handler.audio_decoder.is_some());
assert!(handler.playback_recovery.retry_at.is_none());
}
#[test]
fn superseding_format_preserves_failure_when_both_outputs_failed() {
let (mut handler, old_dropped) = recovery_handler();
let old_errors = handler.playback_recovery.errors.clone();
let (candidate_dropped, status, errors) = begin_pending(&mut handler);
status.ready.store(true, Ordering::Release);
old_errors.push(StreamError::DeviceNotAvailable);
errors.push(StreamError::DeviceNotAvailable);
handler.handle_format_with_start(format(INPUT_RATE, CHANNELS), failed_output);
assert!(candidate_dropped.load(Ordering::SeqCst));
assert!(old_dropped.load(Ordering::SeqCst));
assert!(handler.audio_stream.is_none());
assert!(handler.playback_recovery.retry_at.is_some());
}

View File

@@ -0,0 +1,128 @@
use super::{create_audio_resampler, AudioDecoder, AudioFormat, AudioFrame, AudioHandler, Stereo};
use cpal::traits::StreamTrait;
use hbb_common::{anyhow::anyhow, ResultType};
use magnum_opus::{Application::LowDelay, Encoder};
use ringbuf::Rb;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
const INPUT_RATE: u32 = 24_000;
const OUTPUT_RATE: u32 = 48_000;
const CHANNELS: u16 = 2;
const PACKETS_PER_SECOND: usize = 100;
const MAX_PACKET_BYTES: usize = 4_096;
const SAMPLE_VALUE: f32 = 0.25;
const MONO_CHANNELS: u16 = 1;
#[cfg(target_os = "windows")]
#[path = "audio_playback_recovery_tests.rs"]
mod recovery_tests;
struct TrackedAudioStream(Arc<AtomicBool>);
impl StreamTrait for TrackedAudioStream {
fn play(&self) -> Result<(), cpal::PlayStreamError> {
Ok(())
}
fn pause(&self) -> Result<(), cpal::PauseStreamError> {
Ok(())
}
}
impl Drop for TrackedAudioStream {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
fn decoder(sample_rate: u32) -> (AudioDecoder, Vec<f32>) {
(
AudioDecoder::new(sample_rate, Stereo).unwrap(),
vec![0.0; sample_rate as usize * CHANNELS as usize],
)
}
fn active_handler(input_rate: u32) -> (AudioHandler, Arc<AtomicBool>) {
let dropped = Arc::new(AtomicBool::new(false));
let handler = AudioHandler {
audio_decoder: Some(decoder(input_rate)),
audio_resampler: create_audio_resampler(input_rate, OUTPUT_RATE, CHANNELS).unwrap(),
sample_rate: (input_rate, OUTPUT_RATE),
audio_stream: Some(Box::new(TrackedAudioStream(dropped.clone()))),
channels: CHANNELS,
device_channel: CHANNELS,
..Default::default()
};
handler.playback_status.ready.store(true, Ordering::Release);
(handler, dropped)
}
fn audio_frame() -> AudioFrame {
let samples = OUTPUT_RATE as usize / PACKETS_PER_SECOND * CHANNELS as usize;
let mut encoder = Encoder::new(OUTPUT_RATE, Stereo, LowDelay).unwrap();
AudioFrame {
data: encoder
.encode_vec_float(&vec![SAMPLE_VALUE; samples], MAX_PACKET_BYTES)
.unwrap()
.into(),
..Default::default()
}
}
fn failed_output(candidate: &mut AudioHandler, _: AudioFormat) -> ResultType<()> {
candidate.sample_rate = (INPUT_RATE, INPUT_RATE);
candidate.device_channel = MONO_CHANNELS;
candidate
.audio_buffer
.resize(INPUT_RATE as _, MONO_CHANNELS as _);
candidate
.playback_status
.ready
.store(false, Ordering::Release);
Err(anyhow!("Injected playback failure"))
}
fn format(sample_rate: u32, channels: u16) -> AudioFormat {
AudioFormat {
sample_rate,
channels: u32::from(channels),
..Default::default()
}
}
#[test]
fn identical_format_failure_preserves_playback_and_resampler_history() {
let (mut handler, dropped) = active_handler(INPUT_RATE);
let (mut reference, _) = active_handler(INPUT_RATE);
let (mut retained_decoder, _) = active_handler(INPUT_RATE);
retained_decoder.handle_frame(audio_frame());
retained_decoder.handle_frame(audio_frame());
handler.handle_frame(audio_frame());
reference.handle_frame(audio_frame());
let buffer = handler.audio_buffer.0.clone();
let status = handler.playback_status.clone();
handler.handle_format_with_start(format(INPUT_RATE, CHANNELS), failed_output);
reference.audio_decoder = Some(decoder(INPUT_RATE));
handler.handle_frame(audio_frame());
reference.handle_frame(audio_frame());
assert!(!dropped.load(Ordering::SeqCst));
assert!(Arc::ptr_eq(&buffer, &handler.audio_buffer.0));
assert!(Arc::ptr_eq(&status, &handler.playback_status));
assert_eq!(handler.sample_rate, (INPUT_RATE, OUTPUT_RATE));
assert_eq!(handler.device_channel, CHANNELS);
assert!(handler.playback_status.ready.load(Ordering::Acquire));
let expected = drain_audio(&reference);
let actual = drain_audio(&handler);
assert!(!actual.is_empty());
assert_ne!(drain_audio(&retained_decoder), expected);
assert_eq!(actual, expected);
}
fn drain_audio(handler: &AudioHandler) -> Vec<f32> {
handler.audio_buffer.0.lock().unwrap().pop_iter().collect()
}

View File

@@ -2166,6 +2166,13 @@ pub fn decode_id_pk_dtls(
}
}
/// Whether the DTLS fingerprint a WebRTC peer signed into its identity is the one of the channel
/// actually negotiated. An empty signed value binds nothing: on a WebRTC channel it is either a
/// peer that could not sign one or a rendezvous/relay that stripped it, and both fail closed.
pub fn dtls_fingerprint_bound(signed_fp: &str, actual_fp: &str) -> bool {
!signed_fp.is_empty() && signed_fp == actual_fp
}
pub fn create_symmetric_key_msg(their_pk_b: [u8; 32]) -> (Bytes, Bytes, secretbox::Key) {
let their_pk_b = box_::PublicKey(their_pk_b);
let (our_pk_b, out_sk_b) = box_::gen_keypair();
@@ -3263,4 +3270,42 @@ mod tests {
assert_eq!(combined_mask & MOUSE_TYPE_MASK, MOUSE_TYPE_DOWN);
assert_eq!(combined_mask >> 3, MOUSE_BUTTON_LEFT | MOUSE_BUTTON_RIGHT);
}
#[test]
fn test_dtls_fingerprint_travels_signed_and_binds() {
let (pk, sk) = sign::gen_keypair();
let fp = "sha-256 0A:1B:2C";
let signed = sign::sign(
&IdPk {
id: "123456789".to_owned(),
pk: Bytes::from(vec![7u8; 32]),
dtls_fingerprint: fp.to_owned(),
..Default::default()
}
.write_to_bytes()
.unwrap(),
&sk,
);
let (id, their_pk, signed_fp) = decode_id_pk_dtls(&signed, &pk).unwrap();
assert_eq!(id, "123456789");
assert_eq!(their_pk, [7u8; 32]);
assert_eq!(signed_fp, fp);
assert!(dtls_fingerprint_bound(&signed_fp, fp));
assert!(!dtls_fingerprint_bound(&signed_fp, "sha-256 0A:1B:2D"));
assert!(!dtls_fingerprint_bound("", ""));
// The fingerprint is under the signature: a blob verified with another key yields
// nothing, and one whose payload was edited in transit fails verification.
let (other_pk, _) = sign::gen_keypair();
assert!(decode_id_pk_dtls(&signed, &other_pk).is_err());
let mut tampered = signed.clone();
let last = tampered.len() - 1;
tampered[last] ^= 1;
assert!(decode_id_pk_dtls(&tampered, &pk).is_err());
// `decode_id_pk` is the same blob minus the fingerprint, so the field is invisible to
// non-WebRTC handshakes.
assert_eq!(decode_id_pk(&signed, &pk).unwrap(), (id, their_pk));
}
}

View File

@@ -1497,7 +1497,13 @@ pub async fn start_pa() {
None, // Use default buffering attributes
) {
Ok(s) => loop {
if let Ok(_) = s.read(&mut buf) {
// A dead pulse handle fails every read at once, so ignoring the
// error left nothing pacing this loop and it burned a core.
if let Err(err) = s.read(&mut buf) {
log::error!("Failed to read audio data:{}", err);
break;
}
{
let out =
if buf.iter().filter(|x| **x != 0).next().is_none() {
vec![]

View File

@@ -780,6 +780,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk non è riuscito a caricare un componente GStreamer necessario per l'acquisizione dello schermo ({})"),
("Relay fallback delay in seconds", "Ritardo fallback relay (secondi)"),
("relay-fallback-delay-tip", "Quanto tempo una connessione relay già attiva attende la connessione WebRTC diretta prima di essere usata. Aumentalo per dare a una connessione diretta lenta più tempo per funzionare; diminuiscilo per passare prima al relay sulle reti in cui non è possibile effettuare una connessione diretta. Lascia vuoto per il valore predefinito di 2,5 secondi."),
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "Per avviare una chiamata vocale, attiva nella pagina 'Condivisione schermo' la voce 'Cattura audio'.")
].iter().cloned().collect();
}

View File

@@ -780,6 +780,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk가 화면 캡처에 필요한 GStreamer 구성 요소를 불러오지 못했습니다 ({})"),
("Relay fallback delay in seconds", "릴레이 대체 작동 지연 시간 (초)"),
("relay-fallback-delay-tip", "이미 연결된 중계 연결이 직접 WebRTC 연결을 얼마나 기다린 후 대신 사용되는지입니다. 값을 늘리면 느린 직접 연결에 더 많은 시간을 주고, 줄이면 직접 연결이 불가능한 네트워크에서 더 빨리 중계로 전환합니다. 비워 두면 기본값 2.5초가 사용됩니다."),
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "음성 통화를 시작하려면 '화면 공유' 페이지에서 '오디오 캡처'를 사용함으로 하세요.")
].iter().cloned().collect();
}

View File

@@ -149,7 +149,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Click to upgrade", "Iniciar atualização"),
("Configure", "Configurar"),
("config_acc", "Para controlar seu computador remotamente, você precisa conceder ao RustDesk permissões de \"Acessibilidade\"."),
("config_screen", "Para acessar seu computador remotamente, você precisa conceder ao RustDesk permissões de \"Gravar a Tela\"/"),
("config_screen", "Para acessar seu computador remotamente, você precisa conceder ao RustDesk permissões de \"Gravar a Tela\""),
("Installing ...", "Instalando ..."),
("Install", "Instalar"),
("Installation", "Instalação"),
@@ -764,7 +764,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Sync clipboard between sessions", "Sincronizar área de transferência entre sessões"),
("sync-clipboard-between-sessions-tip", "Texto ou imagens copiados em uma sessão remota também são enviados para a área de transferência das suas outras sessões conectadas."),
("terminal-clipboard-write-tip", "Aplicativos do terminal podem copiar para a área de transferência"),
("Allow terminal apps to copy to clipboard", "Permitir que aplicativos do terminal copiem para a área de transferência"),
("Allow terminal apps to copy to clipboard", "Permitir cópia do terminal para a área de transferência"),
("Enable", "Habilitar"),
("Reuse one connection for port forwarding", "Reutilizar uma conexão para encaminhamento de portas"),
("port-forward-mux-tip", "Levar todas as conexões de um encaminhamento de portas por uma única conexão com o outro computador, em vez de estabelecer uma nova conexão e fazer login novamente para cada uma."),
@@ -779,7 +779,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("RustDesk could not obtain a usable screen from the XDG Desktop Portal, the PipeWire library may be too old", "O RustDesk não conseguiu obter uma tela utilizável do XDG Desktop Portal. A biblioteca do PipeWire pode estar desatualizada."),
("RustDesk could not load a GStreamer component needed for screen capture ({})", "O RustDesk não conseguiu carregar um componente do GStreamer necessário para a captura de tela ({})."),
("Relay fallback delay in seconds", "Atraso antes de recorrer ao retransmissor em segundos"),
("relay-fallback-delay-tip", "Quanto tempo uma conexão de retransmissão já estabelecida espera pela conexão direta WebRTC antes de ser usada no lugar dela. Aumente para dar mais tempo a uma conexão direta lenta; diminua para recorrer mais cedo ao retransmissor em redes onde não é possível uma conexão direta. Deixe vazio para o valor padrão de 2.5 segundos."),
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
("relay-fallback-delay-tip", "Tempo que a conexão de retransmissão aguarda pela conexão direta WebRTC. Aumente para dar mais tempo a conexões lentas; diminua para usar o retransmissor mais cedo. Deixe vazio para usar o padrão de 2,5 segundos."),
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "Para iniciar uma chamada de voz, ative \"Captura de áudio\" na página \"Compartilhamento de tela\".")
].iter().cloned().collect();
}

View File

@@ -730,7 +730,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("preset-password-in-use-tip", "Установленный пароль сейчас используется."),
("Enable privacy mode", "Использовать режим конфиденциальности"),
("allow-remote-toolbar-docking-any-edge", "Разрешать прикрепление удалённой панели инструментов к любому краю окна"),
("API Token", "API-токен"),
("API Token", "Токен API"),
("Deploy", "Развернуть"),
("Custom ID (optional)", "Пользовательский ID (необязательно)"),
("server_requires_deployment_tip", "Сервер требует явного развёртывания этого устройства. Развернуть сейчас?"),
@@ -748,23 +748,23 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Show on the minimized toolbar", "Показывать на свёрнутой панели инструментов"),
("All monitors", "Все мониторы"),
("#{} monitor", "Монитор {}"),
("conn-e2ee-unavailable-tip", "Не удалось проверить сквозное шифрование.\nУдаленное устройство, возможно, еще настраивается. Повторите попытку позже.\nЕсли это повторяется, сервер может быть ненадежным.\nВсе равно продолжить?"),
("conn-e2ee-unavailable-tip", "Невозможно проверить сквозное шифрование.\nУдалённое устройство, возможно, ещё настраивается. Повторите попытку позже.\nЕсли это повторяется, сервер может быть ненадёжным.\nВсё равно продолжить?"),
("ID whitelisting", "Список разрешённых ID"),
("Use ID whitelisting", "Использовать белый список ID"),
("id_whitelist_tip", "Только ID из белого списка могут получить доступ к моему устройству."),
("id_whitelist_wildcard_tip", "Поддерживаются подстановочные знаки: '*' соответствует любому количеству символов, '?' — ровно одному символу"),
("id_whitelist_wildcard_tip", "Поддерживаются подстановочные знаки: \"*\" соответствует любому количеству символов, \"?\" — ровно одному символу"),
("Invalid ID", "Неправильный ID"),
("Your ID is blocked by the peer", "Ваш ID заблокирован удалённым устройством"),
("Your ip is blocked by the peer", "Ваш IP-адрес заблокирован удалённым устройством"),
("id_whitelist_caveat_tip", "ID сообщается подключающимся клиентом. Белый список уменьшает поверхность атаки и не заменяет пароль или 2FA"),
("whitelist_cidr_tip", "Поддерживается нотация CIDR, например 192.168.1.0/24"),
("whitelist_cidr_tip", "Поддерживается нотация CIDR, например: 192.168.1.0/24"),
("Continue", "Продолжить"),
("Browser didn't open? Use the url below to sign in.", "Браузер не открылся? Используйте ссылку ниже для входа."),
("Lock canvas", "Заблокировать холст"),
("Sync clipboard between sessions", "Синхронизировать буфер обмена между сеансами"),
("sync-clipboard-between-sessions-tip", "Текст или изображения, скопированные в одном удалённом сеансе, также отправляются в буфер обмена других подключённых сеансов."),
("terminal-clipboard-write-tip", ""),
("Allow terminal apps to copy to clipboard", ""),
("terminal-clipboard-write-tip", "Приложение в терминале хочет скопировать текст в буфер обмена этого устройства. Если соответствующее разрешение предоставлено, оно применяется к приложениям в терминале во всех соединениях, пока вы не отключите его в настройках. Ручные копирование и вставка не затрагиваются."),
("Allow terminal apps to copy to clipboard", "Разрешить приложениям в терминале копирование в буфер обмена"),
("Enable", "Включить"),
("Reuse one connection for port forwarding", "Использовать одно подключение для перенаправления портов"),
("port-forward-mux-tip", "Передавать все соединения одного перенаправления портов через одно подключение к удалённому устройству вместо повторного подключения и входа для каждого из них."),
@@ -780,6 +780,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("RustDesk could not load a GStreamer component needed for screen capture ({})", "RustDesk не удалось загрузить компонент GStreamer, необходимый для захвата экрана ({})"),
("Relay fallback delay in seconds", "Задержка перед переходом на ретранслятор в секундах"),
("relay-fallback-delay-tip", "Сколько времени уже установленное соединение через ретранслятор ждёт прямое соединение WebRTC, прежде чем будет использовано вместо него. Увеличьте, чтобы дать медленному прямому соединению больше времени; уменьшите, чтобы быстрее переходить на ретранслятор в сетях, где прямое соединение невозможно. Оставьте пустым для значения по умолчанию 2.5 секунды."),
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "")
("To start a voice call, enable \"Audio capture\" on the \"Screen share\" page.", "Чтобы начать голосовой вызов, включите \"Захват аудио\" на странице \"Демонстрация экрана\" настроек.")
].iter().cloned().collect();
}

View File

@@ -542,6 +542,26 @@ extern "C"
SHAddToRecentDocs(SHARD_PATHW, path);
}
// Hyper-V Enhanced Session names do not have the usual "rdp" prefix.
static bool is_rdp_session_by_protocol(DWORD session_id)
{
LPSTR buffer = nullptr;
DWORD bytes = 0;
if (!WTSQuerySessionInformationA(
WTS_CURRENT_SERVER_HANDLE, session_id, WTSClientProtocolType, &buffer, &bytes)) {
flog("Failed to query protocol for session %lu: Windows error %lu\n",
session_id, GetLastError());
return false;
}
std::unique_ptr<char, decltype(&WTSFreeMemory)> protocol_info(buffer, WTSFreeMemory);
if (!buffer || bytes < sizeof(USHORT)) {
flog("Failed to query protocol for session %lu: Windows error %lu\n",
session_id, static_cast<DWORD>(ERROR_INVALID_DATA));
return false;
}
return *reinterpret_cast<const USHORT *>(buffer) == WTS_PROTOCOL_TYPE_RDP;
}
DWORD get_current_session(BOOL include_rdp)
{
auto rdp_or_console = WTSGetActiveConsoleSessionId();
@@ -573,6 +593,10 @@ extern "C"
{
rdp_or_console = info.SessionId;
}
else if (is_rdp_session_by_protocol(info.SessionId))
{
rdp_or_console = info.SessionId;
}
}
}
WTSFreeMemory(pInfos);
@@ -666,6 +690,9 @@ extern "C"
else if (include_rdp && !strnicmp(info.pWinStationName, ica, nica)) {
sessionIds.push_back(std::wstring(L"ICA:") + std::to_wstring(info.SessionId));
}
else if (include_rdp && is_rdp_session_by_protocol(info.SessionId)) {
sessionIds.push_back(std::wstring(L"RDP:") + std::to_wstring(info.SessionId));
}
}
}
WTSFreeMemory(pInfos);

View File

@@ -3,7 +3,7 @@ use std::{
hash::BuildHasher,
net::SocketAddr,
sync::{
atomic::{AtomicBool, Ordering},
atomic::{AtomicBool, AtomicUsize, Ordering},
Arc, RwLock,
},
time::{Duration, Instant},
@@ -63,6 +63,36 @@ const MAX_PENDING_REMOTE_ICE: usize = 64;
/// Queued candidates remembered so the controller's re-send is skipped instead of taking a slot
/// of its own. Far more than an honest peer gathers, at eight bytes each.
const ICE_DEDUP_WINDOW: usize = 256;
/// Answerers between an offer and an open data channel. An offer arrives before any password or
/// accept prompt, and each one builds a peer connection that binds a socket per interface and
/// runs ICE for up to `CONNECT_TIMEOUT`, where a forged TCP punch costs one connect. Past this
/// many the offer is declined, and the controller carries on over punch and relay as it does
/// for a peer without WebRTC. A guard against pathological setup concurrency, above what
/// legitimate controllers reach at once in the seconds ICE takes; once the channel is open the
/// connection is one like any other, and the connection layer bounds unauthenticated
/// connections in number and in time for every transport alike.
const MAX_WEBRTC_ANSWERERS: usize = 16;
static WEBRTC_ANSWERERS: AtomicUsize = AtomicUsize::new(0);
/// One of the `MAX_WEBRTC_ANSWERERS` slots, given back on drop.
struct AnswererSlot;
impl AnswererSlot {
fn take() -> Option<Self> {
WEBRTC_ANSWERERS
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| {
(n < MAX_WEBRTC_ANSWERERS).then(|| n + 1)
})
.ok()
.map(|_| Self)
}
}
impl Drop for AnswererSlot {
fn drop(&mut self) {
WEBRTC_ANSWERERS.fetch_sub(1, Ordering::AcqRel);
}
}
// The rendezvous ICE route is reachable without a prior punch and the peer decides how many
// candidates it sends, so these sites would let someone else set how much this machine writes to
// its log file. One line a minute each, carrying the suppressed count.
@@ -749,6 +779,15 @@ impl RendezvousMediator {
peer_addr: SocketAddr,
meta: ConnectionMeta,
) -> ResultType<String> {
let Some(slot) = AnswererSlot::take() else {
hbb_common::throttled_log!(
ICE_LOG_INTERVAL,
warn,
"declined a WebRTC offer: {} answerers already in flight",
MAX_WEBRTC_ANSWERERS
);
return Ok(String::new());
};
let mut stream =
WebRTCStream::new(&ph.webrtc_sdp_offer, relay_only_ice, CONNECT_TIMEOUT).await?;
let answer = stream.local_endpoint().to_owned();
@@ -849,6 +888,11 @@ impl RendezvousMediator {
let session_key_for_cleanup = session_key.clone();
tokio::spawn(async move {
let result = stream.wait_connected(CONNECT_TIMEOUT).await;
// The slot covers the setup an unauthenticated offer makes this machine pay for, ICE,
// DTLS and SCTP, and that wait is bounded by CONNECT_TIMEOUT. Release it here, before
// the cleanup and the close below, so their duration is never added to a slot's life;
// with the channel open the session is a connection like any other.
drop(slot);
// Only evict our own route. The key is the offer's DTLS fingerprint, identical across
// the controller's punch retries, so a retry that built a fresh answerer has already
// replaced this entry — removing it blindly would delete the live session's sender and
@@ -1488,7 +1532,10 @@ impl Drop for CheckIfResendPk {
#[cfg(test)]
mod tests {
use super::{mpsc, socket_client, tokio, IceRoute, ICE_DEDUP_WINDOW, MAX_PENDING_REMOTE_ICE};
use super::{
mpsc, socket_client, tokio, AnswererSlot, IceRoute, ICE_DEDUP_WINDOW,
MAX_PENDING_REMOTE_ICE, MAX_WEBRTC_ANSWERERS,
};
use hbb_common::tcp::new_listener;
use std::net::SocketAddr;
@@ -1736,4 +1783,14 @@ mod tests {
"must return when the grace runs out, not a backoff later"
);
}
#[test]
fn test_answerer_slots_cap_and_release() {
let held: Vec<_> = (0..MAX_WEBRTC_ANSWERERS)
.map(|_| AnswererSlot::take().unwrap())
.collect();
assert!(AnswererSlot::take().is_none());
drop(held);
assert!(AnswererSlot::take().is_some());
}
}

View File

@@ -118,6 +118,15 @@ pub struct Server {
pub type ServerPtr = Arc<RwLock<Server>>;
pub type ServerPtrWeak = Weak<RwLock<Server>>;
#[cfg(test)]
pub fn new_for_test() -> ServerPtr {
Arc::new(RwLock::new(Server {
connections: HashMap::new(),
services: HashMap::new(),
id_count: 1000,
}))
}
pub fn new() -> ServerPtr {
let mut server = Server {
connections: HashMap::new(),
@@ -204,7 +213,48 @@ pub async fn create_tcp_connection(
meta: ConnectionMeta,
) -> ResultType<()> {
let mut stream = stream;
// The address the connection layer keys on, whitelist and admission alike.
let addr = hbb_common::try_into_v4(addr);
let id = server.write().unwrap().get_new_id();
// Admitted before the identity handshake, so a peer that stalls in it, or after it without
// logging in, holds its place the whole time; an address over its share is turned away.
let Some(unauthorized) = admit_unauthorized(id, addr.ip()) else {
bail!("too many unauthenticated connections from {}", addr.ip());
};
tokio::select! {
handshake = identity_handshake(&mut stream, secure) => handshake?,
_ = unauthorized.evicted() => {
bail!("evicted to make room for a newer unauthenticated connection");
}
}
#[cfg(target_os = "macos")]
{
use std::process::Command;
if let Ok(task) = Command::new("/usr/bin/caffeinate")
.arg("-u")
.arg("-t 5")
.spawn()
{
super::CHILD_PROCESS.lock().unwrap().push(task);
}
log::info!("wake up macos");
}
Connection::start(
addr,
stream,
id,
Arc::downgrade(&server),
meta,
unauthorized,
)
.await;
Ok(())
}
/// Our signed identity goes out and, when `secure`, the controller's reply keys `stream`.
/// Separate so it can be raced against the connection's eviction.
async fn identity_handshake(stream: &mut Stream, secure: bool) -> ResultType<()> {
let (sk, pk) = Config::get_key_pair();
if secure && pk.len() == sign::PUBLICKEYBYTES && sk.len() == sign::SECRETKEYBYTES {
let mut sk_ = [0u8; sign::SECRETKEYBYTES];
@@ -267,19 +317,6 @@ pub async fn create_tcp_connection(
}
}
#[cfg(target_os = "macos")]
{
use std::process::Command;
if let Ok(task) = Command::new("/usr/bin/caffeinate")
.arg("-u")
.arg("-t 5")
.spawn()
{
super::CHILD_PROCESS.lock().unwrap().push(task);
}
log::info!("wake up macos");
}
Connection::start(addr, stream, id, Arc::downgrade(&server), meta).await;
Ok(())
}

View File

@@ -124,7 +124,11 @@ mod pa_impl {
})?;
#[cfg(target_os = "linux")]
if let Ok(data) = stream.next_raw().await {
{
// The `_pa` peer closing surfaces as `Err` here. Dropping it left the loop polling
// a dead socket -- one 0-byte read per poll, ready at once and never `Pending` --
// which burned a full core for the rest of the process lifetime.
let data = stream.next_raw().await?;
if data.len() == 0 {
send_f32(&zero_audio_frame, &mut encoder, &sp);
continue;
@@ -387,6 +391,15 @@ mod cpal_impl {
if !audio_input.is_empty() {
return get_audio_input(&audio_input);
}
// The pinned CPAL uses event-driven WASAPI loopback here. Windows versions
// before Windows 10 1703 do not signal capture events, so system audio does
// not work on Win7. #16095 kept the same CPAL revision and loopback path;
// this limitation predates that PR.
// Ordinary microphone input is supported on Win7 and uses the branch above.
// #16095 added its callback-to-encoder wake dependency; see CapturePcmSender::wake
// for the new scheduling risk, whose audible impact on Win7 is unmeasured.
// https://learn.microsoft.com/en-us/windows/win32/coreaudio/loopback-recording
// https://learn.microsoft.com/en-us/windows/win32/coreaudio/capturesharedeventdriven
let device = HOST
.default_output_device()
.with_context(|| "Failed to get default output device for loopback")?;

View File

@@ -97,6 +97,7 @@ impl Drop for CaptureEncoderWorker {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
if let Some(handle) = self.handle.take() {
// Owner-thread shutdown already waits via join(); see CapturePcmSender::wake for Win7.
handle.thread().unpark();
if let Err(error) = handle.join() {
log::error!("Failed to join audio encoder thread: {error:?}");
@@ -209,6 +210,16 @@ impl CapturePcmSender {
fn wake(&self) {
if let Some(thread) = self.handoff.wake_thread.get() {
// #16095 moved Opus encoding and message submission from the capture callback to a worker.
// Previously, the callback did that work directly, with allocations and blocking locks.
// On Win7 with Rust 1.75, if the worker is descheduled after publishing PARKED but before
// NtWaitForKeyedEvent, unpark() waits in NtReleaseKeyedEvent until the worker enters that wait.
// It does not wait for encoding; park_timeout() does not bound the callback's wait.
// Delays can cause gaps or stall teardown; a Win7 microphone regression has not been measured.
// System loopback already failed on Win7 before #16095 (see cpal_impl::get_device),
// so the affected path is microphone/input-device capture, including outgoing voice calls.
// Accept this risk to preserve Win7 input capture without a separate legacy notifier.
// https://github.com/rust-lang/rust/blob/1.75.0/library/std/src/sys/windows/thread_parking.rs
thread.unpark();
}
}

View File

@@ -55,15 +55,16 @@ use scrap::android::{call_main_service_key_event, call_main_service_pointer_inpu
use scrap::camera;
use serde_derive::Serialize;
use serde_json::{json, value::Value};
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use std::sync::atomic::Ordering;
use std::{
collections::HashSet,
net::Ipv6Addr,
net::{IpAddr, Ipv6Addr},
num::NonZeroI64,
path::PathBuf,
str::FromStr,
sync::{atomic::AtomicI64, mpsc as std_mpsc},
sync::{
atomic::{AtomicBool, AtomicI64, Ordering},
mpsc as std_mpsc,
},
};
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use system_shutdown;
@@ -79,7 +80,104 @@ const FAILURE_IDX_ID_WHITELIST: usize = 2;
// throttles enumeration harder; shorter limits collateral on whitelisted neighbours.
const ID_WHITELIST_FAILURE_DECAY_MINUTES: i32 = 10;
/// A connection not authorized within this long of starting is closed, however alive it
/// keeps itself: a wrong password, a pending 2FA, an accept prompt or an admin-terminal
/// credential prompt still unanswered. The controller reconnects on its own and the prompt
/// comes back. A connection that says nothing at all goes at the 30 s idle timeout already.
const LOGIN_GRACE: Duration = Duration::from_secs(180);
/// Connections between accept and authorization, across every transport: the resource bound.
/// At this many a further arrival is refused and the oldest is told to go, one at a time.
const MAX_UNAUTHORIZED_CONNS: usize = 64;
/// Of those, how many one address may hold at once: a quarter of the room. A fairness cap
/// against the cheapest flood, one host with one address, not a security boundary: any pool
/// of addresses passes it, and the bound above is what holds. Meaningful only while the
/// address is the controller's own, which punch and relay messages carry today.
const MAX_UNAUTHORIZED_CONNS_PER_ADDR: usize = 16;
/// A place among the unauthorized connections, taken before the identity handshake and given
/// back on drop: at authorization, or when the connection ends first. The count of live
/// guards is the bound; an evicted one is told to go and keeps its place until it has.
pub struct UnauthorizedID {
id: i32,
shared: Arc<UnauthorizedShared>,
}
struct UnauthorizedShared {
evicted: AtomicBool,
notify: hbb_common::tokio::sync::Notify,
}
/// Admit a connection from `ip` among the unauthorized ones. `None` when that address already
/// holds its share, or when the global limit is reached: then the oldest connection is told to
/// go, unless one is on its way out already, and this one is refused rather than let in on a
/// place that is still occupied. At most one connection is ever on its way out, so a burst of
/// refused arrivals clears no more room than a single one. The controller retries on its own.
pub fn admit_unauthorized(id: i32, ip: IpAddr) -> Option<UnauthorizedID> {
let mut conns = UNAUTHORIZED_CONNS.lock().unwrap();
if conns.iter().filter(|(_, held, _)| *held == ip).count() >= MAX_UNAUTHORIZED_CONNS_PER_ADDR {
return None;
}
if conns.len() >= MAX_UNAUTHORIZED_CONNS {
if let Some((_, _, oldest)) = conns.first() {
if !oldest.evicted.swap(true, Ordering::AcqRel) {
oldest.notify.notify_one();
}
}
return None;
}
let shared = Arc::new(UnauthorizedShared {
evicted: AtomicBool::new(false),
notify: hbb_common::tokio::sync::Notify::new(),
});
conns.push((id, ip, shared.clone()));
Some(UnauthorizedID { id, shared })
}
impl UnauthorizedID {
/// Whether this connection was told to go to make room for a newer one.
pub fn is_evicted(&self) -> bool {
self.shared.evicted.load(Ordering::Acquire)
}
/// Resolves once this connection is told to go; at once if it already was.
pub async fn evicted(&self) {
if self.is_evicted() {
return;
}
self.shared.notify.notified().await;
}
}
impl Drop for UnauthorizedID {
fn drop(&mut self) {
UNAUTHORIZED_CONNS
.lock()
.unwrap()
.retain(|(id, _, _)| *id != self.id);
}
}
/// Resolves when the connection holding `unauthorized` is evicted; never once it has
/// authorized and given its place back.
async fn unauthorized_evicted(unauthorized: &Option<UnauthorizedID>) {
match unauthorized {
Some(u) => u.evicted().await,
None => std::future::pending().await,
}
}
/// Resolves at the login deadline of a connection started at `started`; never once it has
/// authorized.
async fn login_deadline(authorized: bool, started: Instant) {
if authorized {
return std::future::pending().await;
}
time::sleep_until(started + LOGIN_GRACE).await
}
lazy_static::lazy_static! {
// Connections between accept and authorization, oldest first; see admit_unauthorized.
static ref UNAUTHORIZED_CONNS: Mutex<Vec<(i32, IpAddr, Arc<UnauthorizedShared>)>> = Default::default();
// [0] password, [1] 2FA, [2] ID whitelist.
// Bucket 2 is separate so its rejections do not touch the password / 2FA budgets. It is
// decayed in `check_id_whitelist` and cleared on auth, never on a bare id match.
@@ -264,6 +362,8 @@ pub struct Connection {
port_forward_address: String,
tx_to_cm: mpsc::UnboundedSender<ipc::Data>,
authorized: bool,
// The place among the unauthorized connections; given back at authorization.
unauthorized_id: Option<UnauthorizedID>,
require_2fa: Option<totp_rs::TOTP>,
awaiting_2fa: bool,
keyboard: bool,
@@ -299,12 +399,6 @@ pub struct Connection {
tx_input: std_mpsc::Sender<MessageInput>,
// handle input messages
video_ack_required: bool,
// Diagnostics only, gated by `RUSTDESK_QOS_VERBOSE`: how long the shared
// write path blocked this second. The video send is inline in the message
// loop, so a slow write also delays the delay probe and its reply.
video_send_max_ms: u32,
video_send_sum_ms: u32,
video_send_count: u32,
server_audit_conn: String,
server_audit_file: String,
controlled_context: Option<ControlledContext>,
@@ -425,6 +519,7 @@ impl Connection {
id: i32,
server: super::ServerPtrWeak,
meta: super::ConnectionMeta,
unauthorized: UnauthorizedID,
) {
let super::ConnectionMeta {
control_permissions,
@@ -483,6 +578,7 @@ impl Connection {
port_forward_address: "".to_owned(),
tx_to_cm,
authorized: false,
unauthorized_id: Some(unauthorized),
keyboard: Self::permission(keys::OPTION_ENABLE_KEYBOARD, &control_permissions),
clipboard: Self::permission(keys::OPTION_ENABLE_CLIPBOARD, &control_permissions),
audio: Self::permission(keys::OPTION_ENABLE_AUDIO, &control_permissions),
@@ -510,9 +606,6 @@ impl Connection {
show_my_cursor: false,
tx_input,
video_ack_required: false,
video_send_max_ms: 0,
video_send_sum_ms: 0,
video_send_count: 0,
server_audit_conn: "".to_owned(),
server_audit_file: "".to_owned(),
controlled_context,
@@ -598,6 +691,7 @@ impl Connection {
let mut test_delay_timer =
crate::rustdesk_interval(time::interval_at(Instant::now(), TEST_DELAY_TIMEOUT));
let mut last_recv_time = Instant::now();
let started = Instant::now();
// The connection type is not known until the login request arrives;
// `on_message` picks the type-specific timeout then.
@@ -633,6 +727,17 @@ impl Connection {
tokio::select! {
// biased; // video has higher priority // causing test_delay_timer failed while transferring big file
// Both end an unauthorized connection at once, not on the next timer tick:
// told to go to make room, or past the grace for its authorization. Neither
// fires once the connection has authorized.
_ = unauthorized_evicted(&conn.unauthorized_id) => {
conn.on_close("Timeout", true).await;
break;
}
_ = login_deadline(conn.authorized, started) => {
conn.on_close("Timeout", true).await;
break;
}
Some(data) = rx_from_cm.recv() => {
match data {
ipc::Data::Authorize => {
@@ -959,17 +1064,10 @@ impl Connection {
video_service::notify_video_frame_fetched(vf.display as usize, id, Some(instant.into()));
}
}
let send_begin = video_service::qos_diag_verbose().then(Instant::now);
if let Err(err) = conn.stream.send(&value as &Message).await {
conn.on_close(&err.to_string(), false).await;
break;
}
if let Some(begin) = send_begin {
let blocked = begin.elapsed().as_millis() as u32;
conn.video_send_max_ms = conn.video_send_max_ms.max(blocked);
conn.video_send_sum_ms = conn.video_send_sum_ms.saturating_add(blocked);
conn.video_send_count += 1;
}
},
Some((instant, value)) = rx.recv() => {
let latency = instant.elapsed().as_millis() as i64;
@@ -1056,21 +1154,6 @@ impl Connection {
break;
}
}
if video_service::qos_diag_verbose() && conn.video_send_count > 0 {
// Joined with `qos_trace` on `t`: a probe that waits behind a
// blocked write is not a slow network.
log::debug!(
"qos_send t={} id={id} frames={} send_max={} send_sum={} queued={}",
hbb_common::get_time(),
conn.video_send_count,
conn.video_send_max_ms,
conn.video_send_sum_ms,
rx_video.len()
);
conn.video_send_max_ms = 0;
conn.video_send_sum_ms = 0;
conn.video_send_count = 0;
}
conn.file_remove_log_control.on_timer().drain(..).map(|x| conn.send_to_cm(x)).count();
#[cfg(feature = "hwcodec")]
conn.update_supported_encoding();
@@ -1792,6 +1875,7 @@ impl Connection {
return false;
}
self.authorized = true;
self.unauthorized_id = None;
// Releases the budget `check_id_whitelist` charges against this address: only a peer
// that got this far proved more than a self-reported id.
self.clear_id_whitelist_failures();
@@ -6994,6 +7078,167 @@ mod test {
#[allow(unused)]
use super::*;
// The registry is process-global and the harness runs tests in parallel threads, so every
// test that admits connections holds this first; a poisoned lock is still a lock.
static UNAUTHORIZED_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn unauthorized_count() -> usize {
UNAUTHORIZED_CONNS.lock().unwrap().len()
}
#[test]
fn test_unauthorized_admission_is_per_address() {
let _serial = UNAUTHORIZED_TESTS.lock().unwrap_or_else(|e| e.into_inner());
let a: IpAddr = "203.0.113.1".parse().unwrap();
let b: IpAddr = "203.0.113.2".parse().unwrap();
let held: Vec<_> = (0..MAX_UNAUTHORIZED_CONNS_PER_ADDR as i32)
.map(|i| admit_unauthorized(1_000_000 + i, a).unwrap())
.collect();
assert!(admit_unauthorized(1_000_100, a).is_none());
assert!(
held.iter().all(|u| !u.is_evicted()),
"a refusal evicts nobody"
);
let other = admit_unauthorized(1_000_101, b).unwrap();
assert!(!other.is_evicted());
drop(held);
assert!(admit_unauthorized(1_000_102, a).is_some());
}
#[tokio::test]
async fn test_unauthorized_full_tells_the_oldest_to_go_and_frees_its_place_only_when_it_has() {
let _serial = UNAUTHORIZED_TESTS.lock().unwrap_or_else(|e| e.into_inner());
let mut held: Vec<_> = (0..MAX_UNAUTHORIZED_CONNS as i32)
.map(|i| {
let ip: IpAddr = format!("198.51.100.{}", i + 1).parse().unwrap();
admit_unauthorized(2_000_000 + i, ip).unwrap()
})
.collect();
// At the limit the newcomer is refused, the oldest is told to go, and the count does
// not move: the place is still occupied.
assert!(admit_unauthorized(2_000_999, "198.51.100.250".parse().unwrap()).is_none());
assert!(held[0].is_evicted());
assert!(held[1..].iter().all(|u| !u.is_evicted()));
assert_eq!(unauthorized_count(), MAX_UNAUTHORIZED_CONNS);
hbb_common::timeout(1000, held[0].evicted()).await.unwrap();
// A further arrival while that one is still on its way out tells nobody else to go: a
// burst of refused arrivals clears no more room than a single one.
assert!(admit_unauthorized(2_001_000, "198.51.100.251".parse().unwrap()).is_none());
assert!(held[1..].iter().all(|u| !u.is_evicted()));
// Only once an evicted connection has gone is there a place for a newcomer.
drop(held.remove(0));
assert_eq!(unauthorized_count(), MAX_UNAUTHORIZED_CONNS - 1);
let newcomer = admit_unauthorized(2_001_001, "198.51.100.252".parse().unwrap()).unwrap();
assert!(!newcomer.is_evicted());
assert_eq!(unauthorized_count(), MAX_UNAUTHORIZED_CONNS);
// Full again, the next arrival tells the connection now oldest to go.
assert!(admit_unauthorized(2_001_002, "198.51.100.253".parse().unwrap()).is_none());
assert!(held[0].is_evicted());
assert!(held[1..].iter().all(|u| !u.is_evicted()));
assert!(!newcomer.is_evicted());
}
// The per-address share holds at the limit too: an address can turn out at most that many
// connections, one per place it then takes, and is refused before any eviction from then on.
#[test]
fn test_unauthorized_full_one_address_turns_out_at_most_its_share() {
let _serial = UNAUTHORIZED_TESTS.lock().unwrap_or_else(|e| e.into_inner());
let mut held: Vec<_> = (0..MAX_UNAUTHORIZED_CONNS as i32)
.map(|i| {
let ip: IpAddr = format!("198.51.100.{}", i + 1).parse().unwrap();
admit_unauthorized(3_000_000 + i, ip).unwrap()
})
.collect();
let flooder: IpAddr = "203.0.113.9".parse().unwrap();
let mut taken = Vec::new();
for i in 0..MAX_UNAUTHORIZED_CONNS_PER_ADDR as i32 {
assert!(admit_unauthorized(3_001_000 + i, flooder).is_none());
assert!(held[0].is_evicted());
drop(held.remove(0));
taken.push(admit_unauthorized(3_002_000 + i, flooder).unwrap());
}
assert_eq!(unauthorized_count(), MAX_UNAUTHORIZED_CONNS);
assert!(admit_unauthorized(3_003_000, flooder).is_none());
assert!(held.iter().all(|u| !u.is_evicted()));
assert!(taken.iter().all(|u| !u.is_evicted()));
}
/// A loopback TCP connection as create_tcp_connection sees it, and the controller's end,
/// which never speaks: the connection stalls in the identity handshake.
async fn stalled_incoming() -> (Stream, Stream, SocketAddr) {
let listener = hbb_common::tcp::new_listener("127.0.0.1:0", false)
.await
.unwrap();
let host = listener.local_addr().unwrap().to_string();
let controller = hbb_common::socket_client::connect_tcp(host, 3000)
.await
.unwrap();
let (accepted, addr) = listener.accept().await.unwrap();
let served = Stream::Tcp(hbb_common::tcp::FramedStream::from(accepted, addr));
(served, controller, addr)
}
// Live connections, not bookkeeping: with the limit reached by connections stalled in the
// handshake, one more arrival is refused and the oldest handshake is ended at once, not on
// a timer tick, so the live count never exceeds the limit and a place opens only then.
#[tokio::test]
async fn test_unauthorized_limit_bounds_live_handshakes() {
let _serial = UNAUTHORIZED_TESTS.lock().unwrap_or_else(|e| e.into_inner());
let server = crate::server::new_for_test();
let mut controllers = Vec::new();
let mut handshakes = Vec::new();
for i in 0..MAX_UNAUTHORIZED_CONNS {
let (served, controller, _) = stalled_incoming().await;
controllers.push(controller);
// Each from an address of its own, so only the global limit is in play.
let addr: SocketAddr = format!("192.0.2.{}:1", i + 1).parse().unwrap();
let server = server.clone();
handshakes.push(tokio::spawn(async move {
crate::server::create_tcp_connection(server, served, addr, true, Default::default())
.await
}));
}
for _ in 0..200 {
if unauthorized_count() == MAX_UNAUTHORIZED_CONNS {
break;
}
hbb_common::sleep(0.02).await;
}
assert_eq!(unauthorized_count(), MAX_UNAUTHORIZED_CONNS);
assert!(handshakes.iter().all(|h| !h.is_finished()));
let (served, _controller, _) = stalled_incoming().await;
let addr: SocketAddr = "192.0.2.200:1".parse().unwrap();
let refused = crate::server::create_tcp_connection(
server.clone(),
served,
addr,
true,
Default::default(),
)
.await;
assert!(refused.is_err());
let ended = hbb_common::timeout(2000, handshakes.remove(0)).await;
assert!(
matches!(ended, Ok(Ok(Err(_)))),
"the oldest handshake ends on eviction"
);
assert_eq!(unauthorized_count(), MAX_UNAUTHORIZED_CONNS - 1);
assert!(
handshakes.iter().all(|h| !h.is_finished()),
"only the oldest was ended"
);
drop(controllers);
for h in handshakes {
assert!(
matches!(hbb_common::timeout(3000, h).await, Ok(Ok(Err(_)))),
"a stalled handshake ends when its controller goes"
);
}
assert_eq!(unauthorized_count(), 0, "no handshake outlives the test");
}
#[cfg(feature = "flutter")]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[test]

View File

@@ -625,7 +625,7 @@ impl VideoQoS {
.clamp(MIN_AUTO_FPS.min(user_cap), user_cap)
.min(current);
user.delay.fps = Some(fps);
log::debug!(
log::trace!(
"qos_trace t={} id={id} timeout={elapsed} fps={fps}",
hbb_common::get_time()
);

View File

@@ -52,6 +52,8 @@ use scrap::{
CodecFormat, Display, EncodeInput, TraitCapturer, TraitPixelBuffer,
};
#[cfg(windows)]
use std::io::ErrorKind::ConnectionReset;
#[cfg(windows)]
use std::sync::Once;
use std::{
collections::HashSet,
@@ -62,6 +64,59 @@ use std::{
pub const OPTION_REFRESH: &'static str = "refresh";
#[cfg(windows)]
const DXGI_RECOVERY_LIMIT: usize = 3;
#[cfg(windows)]
const DXGI_RECOVERY_WINDOW: Duration = Duration::from_secs(10);
#[cfg(windows)]
const DXGI_RECOVERY_FRAME_GRACE: Duration = Duration::from_secs(2);
#[cfg(windows)]
struct DxgiRecoveryState {
attempts: usize,
window_started: Option<Instant>,
restart_pending: bool,
fallback_pending: bool,
}
#[cfg(windows)]
impl DxgiRecoveryState {
fn new() -> Self {
Self {
attempts: 0,
window_started: None,
restart_pending: false,
fallback_pending: false,
}
}
fn next_attempt(&mut self) -> Option<usize> {
if self
.window_started
.map(|started| started.elapsed() > DXGI_RECOVERY_WINDOW)
.unwrap_or(true)
{
self.attempts = 0;
self.window_started = Some(Instant::now());
}
if self.attempts >= DXGI_RECOVERY_LIMIT {
self.fallback_pending = true;
return None;
}
self.attempts += 1;
self.restart_pending = true;
Some(self.attempts)
}
fn take_restart_pending(&mut self) -> bool {
std::mem::take(&mut self.restart_pending)
}
fn take_fallback_pending(&mut self) -> bool {
std::mem::take(&mut self.fallback_pending)
}
}
type FrameFetchedNotifierSender = UnboundedSender<(i32, Option<Instant>)>;
type FrameFetchedNotifierReceiver = Arc<TokioMutex<UnboundedReceiver<(i32, Option<Instant>)>>>;
@@ -220,6 +275,8 @@ pub struct VideoService {
sp: GenericService,
idx: usize,
source: VideoSource,
#[cfg(windows)]
dxgi_recovery_state: Arc<Mutex<DxgiRecoveryState>>,
}
impl Deref for VideoService {
@@ -253,6 +310,8 @@ pub fn new(source: VideoSource, idx: usize) -> GenericService {
sp: GenericService::new(get_service_name(source, idx), true),
idx,
source,
#[cfg(windows)]
dxgi_recovery_state: Arc::new(Mutex::new(DxgiRecoveryState::new())),
};
GenericService::run(&vs, run);
vs.sp
@@ -565,9 +624,25 @@ fn run(vs: VideoService) -> ResultType<()> {
let last_portable_service_running = false;
let display_idx = vs.idx;
#[cfg(windows)]
let dxgi_recovery_state = vs.dxgi_recovery_state.clone();
let sp = vs.sp;
let mut c = get_capturer(vs.source, display_idx, last_portable_service_running)?;
#[cfg(windows)]
// ACCESS_LOST marks the next successful capturer creation as a recovery. This timestamp is
// consumed once and temporarily holds off the normal WouldBlock-to-GDI fallback, giving the
// replacement DXGI capturer time to produce its first frame. Normal startup is unaffected.
let dxgi_recovery_started = dxgi_recovery_state
.lock()
.unwrap()
.take_restart_pending()
.then(Instant::now);
#[cfg(windows)]
if dxgi_recovery_state.lock().unwrap().take_fallback_pending() {
c.set_gdi();
log::info!("dxgi recovery exhausted, fall back to gdi");
}
#[cfg(windows)]
if !scrap::codec::enable_directx_capture() && !c.is_gdi() {
log::info!("disable dxgi with option, fall back to gdi");
c.set_gdi();
@@ -655,12 +730,6 @@ fn run(vs: VideoService) -> ResultType<()> {
let capture_width = c.width;
let capture_height = c.height;
let (mut second_instant, mut send_counter) = (Instant::now(), 0);
// Diagnostics only. `send_counter` counts capture rounds, which is not the
// number of frames that reached a connection: the encoder's own rate control
// drops frames when the bitrate cannot carry them. `wait_max_ms` is how long
// a round waited for the previous frame to be picked up, so a blocked write
// shows up here as capture stalling rather than as a slow network.
let (mut sent_counter, mut wait_max_ms) = (0usize, 0u32);
while sp.ok() {
#[cfg(windows)]
@@ -671,8 +740,6 @@ fn run(vs: VideoService) -> ResultType<()> {
&mut spf,
client_record,
&mut send_counter,
&mut sent_counter,
&mut wait_max_ms,
&mut second_instant,
&sp.name(),
)?;
@@ -793,9 +860,6 @@ fn run(vs: VideoService) -> ResultType<()> {
capture_width,
capture_height,
)?;
if !send_conn_ids.is_empty() {
sent_counter += 1;
}
frame_controller.set_send(now, send_conn_ids);
send_counter += 1;
}
@@ -815,13 +879,18 @@ fn run(vs: VideoService) -> ResultType<()> {
match res {
Err(ref e) if e.kind() == WouldBlock => {
#[cfg(windows)]
if try_gdi > 0 && !c.is_gdi() {
if try_gdi > 3 {
c.set_gdi();
try_gdi = 0;
log::info!("No image, fall back to gdi");
if dxgi_recovery_started
.map(|started| started.elapsed() >= DXGI_RECOVERY_FRAME_GRACE)
.unwrap_or(true)
{
if try_gdi > 0 && !c.is_gdi() {
if try_gdi > 3 {
c.set_gdi();
try_gdi = 0;
log::info!("No image, fall back to gdi");
}
try_gdi += 1;
}
try_gdi += 1;
}
#[cfg(target_os = "linux")]
{
@@ -855,15 +924,19 @@ fn run(vs: VideoService) -> ResultType<()> {
capture_width,
capture_height,
)?;
if !send_conn_ids.is_empty() {
sent_counter += 1;
}
frame_controller.set_send(now, send_conn_ids);
send_counter += 1;
}
}
}
Err(err) => {
#[cfg(windows)]
// The display-change check can restart capture before error handling below.
let recovery_attempt = if !c.is_gdi() && err.kind() == ConnectionReset {
dxgi_recovery_state.lock().unwrap().next_attempt()
} else {
None
};
// This check may be redundant, but it is better to be safe.
// The previous check in `sp.is_option_true(OPTION_REFRESH)` block may be enough.
if vs.source.is_monitor() {
@@ -872,6 +945,19 @@ fn run(vs: VideoService) -> ResultType<()> {
#[cfg(windows)]
if !c.is_gdi() {
if err.kind() == ConnectionReset {
if let Some(attempt) = recovery_attempt {
log::debug!(
"dxgi access lost, restart capture: attempt {attempt}, error: {err:?}"
);
bail!("SWITCH");
}
log::warn!(
"dxgi access lost after {DXGI_RECOVERY_LIMIT} restarts in {} seconds, fall back to gdi: {err:?}",
DXGI_RECOVERY_WINDOW.as_secs()
);
dxgi_recovery_state.lock().unwrap().take_fallback_pending();
}
c.set_gdi();
log::info!("dxgi error, fall back to gdi: {:?}", err);
continue;
@@ -899,7 +985,6 @@ fn run(vs: VideoService) -> ResultType<()> {
break;
}
}
wait_max_ms = wait_max_ms.max(wait_begin.elapsed().as_millis() as u32);
DISPLAY_CONN_IDS.lock().unwrap().remove(&display_idx);
let elapsed = now.elapsed();
@@ -1330,22 +1415,12 @@ pub fn make_display_changed_msg(
Some(msg_out)
}
/// Per-second pipeline diagnostics, off unless `RUSTDESK_QOS_VERBOSE` is set.
/// The default log level is `debug`, so an unconditional line here would land in
/// every user's log file once a second forever. Nothing enables it implicitly.
pub(crate) fn qos_diag_verbose() -> bool {
static VERBOSE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*VERBOSE.get_or_init(|| std::env::var("RUSTDESK_QOS_VERBOSE").is_ok())
}
fn check_qos(
encoder: &mut Encoder,
ratio: &mut f32,
spf: &mut Duration,
client_record: bool,
send_counter: &mut usize,
sent_counter: &mut usize,
wait_max_ms: &mut u32,
second_instant: &mut Instant,
name: &str,
) -> ResultType<()> {
@@ -1371,21 +1446,7 @@ fn check_qos(
if second_instant.elapsed() > Duration::from_secs(1) {
*second_instant = Instant::now();
video_qos.update_display_data(&name, *send_counter);
// Diagnostics only, joined with `qos_trace` on `t`: the controller's target
// is not the rate the encoder produced, and neither is the rate the send
// path accepted.
if qos_diag_verbose() {
log::debug!(
"qos_video t={} display={name} captured={} sent={} wait_max={}",
hbb_common::get_time(),
*send_counter,
*sent_counter,
*wait_max_ms
);
}
*send_counter = 0;
*sent_counter = 0;
*wait_max_ms = 0;
}
drop(video_qos);
Ok(())