test: remove tests that do not pin user-visible behavior (#16539)

* test: remove tests that do not pin user-visible behavior

Apply the AGENTS.md test rules to the existing Rust and Flutter suites.
A test is kept when it is the only coverage of user- or API-visible
behavior, the regression test of a reported or fixed bug, a security
boundary (one test per rejection class), or a check against an external
reference. Tests that assert private state or the order of internal
steps, pin review-round corner cases, or repeat what a kept test already
covers are removed.

Rust: 679 -> 298 tests. Dart: 118 -> 45 tests.

Test-only helpers left without callers go with them
(applyLastOnlineStateForTesting, terminalKeyboardRowWidth,
debugBufferedInputCount, tokio's test-util dev feature), and cfg gates
and visibility that existed only for the deleted tests are narrowed.
Runtime behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WS1HTjp6UQuG43KEcQPq1R

* test: keep tests that pin security and user-visible behavior

Restore six tests that review showed are the only coverage of their
behavior:

- update metadata rejects an artifact file name with path components;
- ICE candidates from another session are dropped;
- KCP delivers every multi-segment frame queued before a close;
- set_files rejects an empty name in a multi-file transfer;
- a lopsided elongated cursor keeps its centre hotspot (#16242);
- toggling Show hidden during a pending same-path read issues a new read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WS1HTjp6UQuG43KEcQPq1R

* test: keep offline update and key-exchange downgrade rejections

Restore three tests that are the only coverage of their rejection class:

- offline update metadata rejects a signed artifact with an http or
  credential-bearing URL;
- offline verification rejects a renamed local file and a version that
  does not match the release;
- a key-exchange pick lowered in transit leaves both sides unable to
  decrypt, so a v0 fallback after a failed v1 decrypt cannot pass
  unnoticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WS1HTjp6UQuG43KEcQPq1R

* test: keep the decomposed-grapheme and reconnect-cancel tests

Each catches a plausible wrong change that passes every kept test:
mapping the first scalar under a locked Ctrl turns é into \x05,
and a reconnect that does not fail pending reads leaves them hanging
until the 30 s timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WS1HTjp6UQuG43KEcQPq1R

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
RustDesk
2026-10-14 14:45:22 +08:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 76ab280ae9
commit 9997b7a3b8
94 changed files with 66 additions and 9405 deletions
+1 -1
View File
@@ -273,7 +273,7 @@ os-version = "0.2"
[dev-dependencies]
hound = "3.5"
docopt = "1.1"
tokio = { version = "1.44", features = ["test-util"] }
tokio = "1.44"
[package.metadata.bundle]
name = "RustDesk"
@@ -271,11 +271,6 @@ class AllPeersLoader {
void updateOnlineStateForTesting(Map<String, dynamic> evt) {
_updateOnlineState(evt);
}
@visibleForTesting
bool applyLastOnlineStateForTesting(List<Peer> peers) {
return _applyLastOnlineState(peers);
}
}
class AutocompletePeerTile extends StatefulWidget {
@@ -11,10 +11,3 @@ const terminalKeyboardKeySpacing = 2.0;
/// Empty 48dp slots keep expanded Row3 aligned with the two rows above it.
const terminalKeyboardRow3TrailingPlaceholderCount = 2;
/// Returns the fixed width occupied by a row of equally sized key slots.
double terminalKeyboardRowWidth(int slotCount) {
if (slotCount <= 0) return 0;
return slotCount * terminalKeyboardKeyWidth +
(slotCount - 1) * terminalKeyboardKeySpacing;
}
-4
View File
@@ -70,10 +70,6 @@ class TerminalModel with ChangeNotifier {
final _inputBuffer = <String>[];
/// Exposes buffered input only for lifecycle regression tests.
@visibleForTesting
int get debugBufferedInputCount => _inputBuffer.length;
// Buffer for output data received before terminal view has valid dimensions.
// This prevents NaN errors when writing to terminal before layout is complete.
final _pendingOutputChunks = <String>[];
@@ -48,74 +48,6 @@ void main() {
expect(peers.single.online, isTrue);
});
test('peer copies preserve online state', () {
final peer = _peer(id: '987654321', online: true);
expect(Peer.copy(peer).online, isTrue);
});
test('online callbacks update autocomplete-only peers', () {
final peers = mergeAutocompletePeers(restRecentPeerIds: ['112233445']);
final changed = updateAutocompletePeerOnlineStates(
peers,
onlines: {'112233445'},
offlines: {},
);
expect(changed, isTrue);
expect(peers.single.online, isTrue);
});
test('online query ids are deduplicated and limited', () {
final peers = List.generate(
25,
(index) => _peer(id: index.toString()),
)..insert(1, _peer(id: '0'));
final ids = autocompleteOnlineQueryIds(peers, limit: 20);
expect(ids, hasLength(20));
expect(ids.first, '0');
expect(ids.where((id) => id == '0'), hasLength(1));
expect(ids.last, '19');
});
test('empty online query ids cancel pending debounce', () async {
final queriedIds = <List<String>>[];
final loader = AllPeersLoader(
queryOnlines: (ids) async {
queriedIds.add(ids);
},
queryOnlineDebounce: Duration(milliseconds: 1),
);
loader.queryOnlines([_peer(id: '123456789')]);
loader.queryOnlines([]);
await Future.delayed(Duration(milliseconds: 2));
expect(queriedIds, isEmpty);
});
test('failed online query enqueue does not suppress retry', () async {
var queryCount = 0;
final loader = AllPeersLoader(
queryOnlines: (ids) {
queryCount += 1;
return Future<void>.error(Exception('queue full'));
},
queryOnlineDebounce: Duration(milliseconds: 1),
);
loader.queryOnlines([_peer(id: '123456789')]);
await Future.delayed(Duration(milliseconds: 2));
loader.queryOnlines([_peer(id: '123456789')]);
await Future.delayed(Duration(milliseconds: 2));
expect(queryCount, 2);
});
test('online callback updates currently displayed options', () async {
final loader = AllPeersLoader(
queryOnlines: (ids) async {},
@@ -132,17 +64,4 @@ void main() {
expect(displayedOptions.single.online, isTrue);
await Future.delayed(Duration(milliseconds: 2));
});
test('cached online callback state is reapplied after peers merge', () {
final loader = AllPeersLoader();
loader.updateOnlineStateForTesting({
'onlines': '123456789',
'offlines': '',
});
final mergedPeers = [_peer(id: '123456789')];
loader.applyLastOnlineStateForTesting(mergedPeers);
expect(mergedPeers.single.online, isTrue);
});
}
-20
View File
@@ -1,20 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'cm_demo.dart' as cm_demo;
void main() {
test('connection manager demo clients match the current Client API', () {
expect(cm_demo.testClients, hasLength(4));
expect(cm_demo.testClients.map((client) => client.name), [
'UserAAAAAA',
'UserBBBBB',
'UserC',
'UserDDDDDDDDDDDd',
]);
expect(
cm_demo.testClients.every(
(client) => client.keyboard && !client.clipboard && !client.audio),
isTrue,
);
});
}
+2 -460
View File
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
@@ -15,36 +14,14 @@ class _Cursor extends CursorModel {
_Cursor(FFI ffi) : super(WeakReference(ffi));
final core = <String, CursorShape>{};
final fetched = <String>[];
// While set, a fetch answers only when [answerFetches] is called.
bool holdFetches = false;
// While set, a fetch throws before it returns a future.
bool throwAtOnce = false;
final _held = <Completer<CursorShape?>, String>{};
@override
Future<CursorShape?> fetchCursorShape(String id) {
fetched.add(id);
if (throwAtOnce) throw StateError('no core');
if (!holdFetches) return Future.value(core[id]);
final answer = Completer<CursorShape?>();
_held[answer] = id;
return answer.future;
return Future.value(core[id]);
}
void answerFetches() {
final held = Map.of(_held);
_held.clear();
held.forEach((answer, id) => answer.complete(core[id]));
}
void failFetches() {
final held = Map.of(_held);
_held.clear();
held.forEach((answer, _) => answer.completeError(StateError('failed')));
}
bool showRemoteCursor = false;
@override
bool get showsRemoteCursor => showRemoteCursor;
bool get showsRemoteCursor => false;
}
String? _key(MouseCursor cursor) =>
@@ -181,28 +158,6 @@ void main() {
expect(registered.length, 100);
});
test(
'a raster made before is found again after the shape was switched away from',
() async {
final cursor = ffi.cursorModel;
await _feed(ffi, 'A', size: 32);
buildCursorOfCache(cursor, 1.0, cursor.cache);
await _settle();
final first = registered.single;
buildCursorOfCache(cursor, 0.5, cursor.cache);
await _settle();
await _feed(ffi, 'B');
buildCursorOfCache(cursor, 1.0, cursor.cache);
await _settle();
final count = registered.length;
_select(ffi, 'A'); // its pixels went; its cursors at both rasters stay
expect(_key(buildCursorOfCache(cursor, 1.0, cursor.cache)), first);
await _settle();
expect(ffi.cursor.fetched, isEmpty);
expect(registered.length, count);
});
test('a new raster of a shape without pixels keeps the cursor shown before',
() async {
final cursor = ffi.cursorModel;
@@ -220,134 +175,6 @@ void main() {
cursor.nativeKey(cursor.cache!, 0.5));
});
test('shapes that keep decoding late still get their native cursors',
() async {
final cursor = ffi.cursorModel;
await _feed(ffi, 'arrow');
buildCursorOfCache(cursor, 1.0, cursor.cache);
await _settle();
// A and B arrive, each decoding only after the peer moved on.
for (final (id, seed) in [('A', 1), ('B', 2)]) {
final pixels = _pixels(8, seed);
ffi.cursor.core[id] =
CursorShape(hotx: 0, hoty: 0, width: 8, height: 8, colors: pixels);
final decoding = ffi.ffiModel.handleCursorData(id, 0, 0, 8, 8, pixels);
_select(ffi, 'arrow');
await decoding;
}
// Each fetch answers only after the next switch.
ffi.cursor.holdFetches = true;
for (var i = 0; i < 6; i++) {
_select(ffi, i.isEven ? 'A' : 'B');
ffi.cursor.answerFetches();
await _settle();
buildCursorOfCache(cursor, 1.0, cursor.cache);
await _settle();
}
expect(registered.where((key) => key.contains('_A_')), isNotEmpty);
expect(registered.where((key) => key.contains('_B_')), isNotEmpty);
});
test('a zoom still gets native cursors for shapes that keep decoding late',
() async {
final cursor = ffi.cursorModel;
// An animation whose frames have native cursors at 1.0 only.
for (final (id, seed) in [('A', 1), ('B', 2)]) {
await _feed(ffi, id, size: 32, seed: seed);
buildCursorOfCache(cursor, 1.0, cursor.cache);
await _settle();
}
// At 0.5 each frame is fetched again, and each fetch answers after the next switch.
ffi.cursor.holdFetches = true;
for (var i = 0; i < 6; i++) {
_select(ffi, i.isEven ? 'A' : 'B');
ffi.cursor.answerFetches();
await _settle();
buildCursorOfCache(cursor, 0.5, cursor.cache);
await _settle();
}
expect(
registered
.where((key) => key.contains('_A_') && key.endsWith('_16_16')),
isNotEmpty);
expect(
registered
.where((key) => key.contains('_B_') && key.endsWith('_16_16')),
isNotEmpty);
});
test(
'a raster still missing keeps a late restore, whatever was shown meanwhile',
() async {
final cursor = ffi.cursorModel;
await _feed(ffi, 'A', size: 32);
buildCursorOfCache(cursor, 1.0, cursor.cache);
await _settle();
await _feed(ffi, 'B');
buildCursorOfCache(cursor, 1.0, cursor.cache);
await _settle();
_select(ffi, 'A');
ffi.cursor.holdFetches = true;
buildCursorOfCache(cursor, 0.5, cursor.cache); // no cursor at 0.5: fetched
buildCursorOfCache(cursor, 1.0, cursor.cache); // 1.0 has one meanwhile
_select(ffi, 'B');
ffi.cursor.answerFetches();
await _settle();
ffi.cursor.fetched.clear();
_select(ffi, 'A');
buildCursorOfCache(cursor, 0.5, cursor.cache);
await _settle();
expect(ffi.cursor.fetched, isEmpty,
reason: 'its pixels waited for the cursor at 0.5');
expect(
registered
.where((key) => key.contains('_A_') && key.endsWith('_16_16')),
isNotEmpty);
});
test('shapes waiting for a native cursor keep their pixels, within the limit',
() async {
const max = CursorModel.kRecentShapes;
for (var i = 0; i <= max + 1; i++) {
await _feed(ffi, '$i');
}
expect(ffi.cursorModel.cachedShape('0')!.hasPixels, isFalse,
reason: 'the one waiting longest is let go');
expect(ffi.cursorModel.cachedShape('1')!.hasPixels, isTrue);
expect(ffi.cursorModel.cachedShape('$max')!.hasPixels, isTrue);
});
test('two animated cursors and the everyday set keep their native cursors',
() async {
// Each frame is a shape of its own: 18 for the Windows busy cursor, 23 for
// KDE Breeze's wait and progress cursors on X11.
const frames = 23;
const statics = 12;
final cursor = ffi.cursorModel;
final shapes = [
for (var i = 0; i < statics; i++) 's$i',
for (var i = 0; i < frames; i++) 'wait$i',
for (var i = 0; i < frames; i++) 'progress$i',
];
for (var i = 0; i < shapes.length; i++) {
await _feed(ffi, shapes[i], seed: i);
buildCursorOfCache(cursor, 1.0, cursor.cache);
await _settle();
}
ffi.cursor.fetched.clear();
for (final id in shapes) {
_select(ffi, id);
buildCursorOfCache(cursor, 1.0, cursor.cache);
await _settle();
}
expect(ffi.cursor.fetched, isEmpty,
reason: 'every shape kept its native cursor');
expect(registered.length, shapes.length);
expect(deleted, isEmpty);
});
test('a shape painted again is decoded again, the last one shown meanwhile',
() async {
await _feed(ffi, '1', size: 16);
@@ -360,89 +187,6 @@ void main() {
expect(ffi.cursorModel.shapeIds, ['1']);
});
test('a shape the core gives but that does not decode is asked for once',
() async {
await _feed(ffi, '1');
await _feed(ffi, '2');
ffi.cursor.core['1'] = CursorShape(
hotx: 0, hoty: 0, width: 8, height: 8, colors: Uint8List(3));
_select(ffi, '1');
for (var i = 0; i < 5; i++) {
ffi.cursorModel.image;
await _settle();
}
expect(ffi.cursor.fetched, ['1']);
});
test('a shape switched away from while it decoded is asked for again',
() async {
await _feed(ffi, '1', size: 16);
await _feed(ffi, '2');
_select(ffi, '1');
ffi.cursorModel.image; // asks the core
// The core has answered and the shape is decoding when the peer moves on.
await Future<void>.delayed(Duration.zero);
_select(ffi, '2');
await _settle();
expect(ffi.cursorModel.cachedShape('1')!.hasPixels, isTrue,
reason:
'decoded after the peer moved on, it waits for its native cursor');
_select(ffi, '1');
ffi.cursorModel.image;
await _settle();
expect(ffi.cursor.fetched, ['1', '1']);
expect(ffi.cursorModel.image?.width, 16);
});
test('a shape decoded after the session was cleared is not kept', () async {
await _feed(ffi, '1', size: 16);
await _feed(ffi, '2');
_select(ffi, '1');
ffi.cursorModel.image; // asks the core
ffi.cursorModel.clear();
await _settle();
expect(ffi.cursorModel.shapeIds, isEmpty);
expect(ffi.cursorModel.cache, isNull);
});
test('a shape the core does not have is asked for once', () async {
await _feed(ffi, '1');
await _feed(ffi, '2');
ffi.cursor.core.remove('1');
_select(ffi, '1');
for (var i = 0; i < 5; i++) {
ffi.cursorModel.image;
await _settle();
}
expect(ffi.cursor.fetched, ['1']);
});
test('a shape the core lacks is remembered only while it is in use',
() async {
await _feed(ffi, '1');
for (var i = 0; i < 1000; i++) {
_select(ffi, 'missing$i');
ffi.cursorModel.image;
}
await _settle();
expect(ffi.cursor.fetched.length, 1000, reason: 'each is asked for once');
_select(ffi, 'missing0');
await _settle();
expect(ffi.cursor.fetched.length, 1001,
reason: 'nothing is kept for a shape no longer in use');
});
test('a shape the core lacks is asked for again after another was in use',
() async {
await _feed(ffi, '1');
_select(ffi, 'missing0');
await _settle();
_select(ffi, '1');
_select(ffi, 'missing0');
await _settle();
expect(ffi.cursor.fetched, ['missing0', 'missing0']);
});
test('a moved tab carries the id in use, and its window asks the core',
() async {
await _feed(ffi, '1', size: 16);
@@ -463,30 +207,6 @@ void main() {
expect(moved.cursorModel.image?.width, 16);
});
test('a shape keeps a native cursor for each raster it was shown at',
() async {
final cursor = ffi.cursorModel;
await _feed(ffi, '1', size: 32);
for (final scale in [1.0, 0.5, 1.0, 0.5]) {
buildCursorOfCache(cursor, scale, cursor.cache);
await _settle();
}
expect(registered.length, 2, reason: 'a raster returned to is reused');
expect(deleted, isEmpty);
expect(cursor.cachedKeys, registered.toSet());
});
test('a cleared session forgets the native cursors it deleted', () async {
final cursor = ffi.cursorModel;
await _feed(ffi, '1');
buildCursorOfCache(cursor, 1.0, cursor.cache);
await _settle();
cursor.clear();
await _settle();
expect(deleted, registered);
expect(cursor.cachedKeys, isEmpty);
});
test('a tab closing does not delete the cursors of another tab', () async {
final other = _FFI();
addTearDown(other.cursorModel.disposeImages);
@@ -502,182 +222,4 @@ void main() {
await _settle();
expect(other.cursorModel.cachedKeys.single, isNot(isIn(deleted)));
});
test('a shape given as a view into a larger buffer is read from the view',
() async {
final pixels = _pixels(8, 3);
final padded = Uint8List(16 + pixels.length)
..setRange(16, 16 + pixels.length, pixels);
await ffi.ffiModel
.handleCursorData('1', 0, 0, 8, 8, Uint8List.sublistView(padded, 16));
final p = ffi.cursorModel.cache!.image.getPixel(0, 0);
expect([p.r, p.g, p.b, p.a], [3, 4, 5, 6]);
});
test('a shape still decoding leaves the one shown before in place', () async {
final cursor = ffi.cursorModel;
await _feed(ffi, '1');
final shown = _key(buildCursorOfCache(cursor, 1.0, cursor.cache));
await _settle();
_select(ffi, '2'); // its shape has not decoded yet
expect(cursor.cache?.id, '1');
expect(_key(buildCursorOfCache(cursor, 1.0, cursor.cache)), shown);
expect(cursor.image, isNotNull);
await _feed(ffi, '2');
expect(cursor.cache?.id, '2');
});
for (final how in ['does not have', 'cannot decode']) {
test('a shape the core $how leaves the default cursor, not the one before',
() async {
final cursor = ffi.cursorModel;
await _feed(ffi, '1');
if (how == 'cannot decode') {
ffi.cursor.core['2'] = CursorShape(
hotx: 0, hoty: 0, width: 8, height: 8, colors: Uint8List(3));
}
_select(ffi, '2');
expect(cursor.cache?.id, '1', reason: 'kept while the shape may come');
var notified = 0;
cursor.addListener(() => notified++);
await _settle();
expect(cursor.cache, isNull,
reason: 'the desktop shows preDefaultCursor');
expect(cursor.image, isNull, reason: 'and so does a painted cursor');
expect(notified, greaterThan(0));
});
}
test('a shape sent that does not decode leaves the default cursor', () async {
final cursor = ffi.cursorModel;
await _feed(ffi, '1');
ffi.cursor.core['2'] = CursorShape(
hotx: 0, hoty: 0, width: 8, height: 8, colors: Uint8List(3));
await ffi.ffiModel.handleCursorData('2', 0, 0, 8, 8, Uint8List(3));
expect(cursor.cache, isNull);
expect(cursor.image, isNull);
_select(ffi, '2');
await _settle();
expect(ffi.cursor.fetched, isEmpty, reason: 'it is not asked for either');
});
test('a fetch failing after the session was cleared marks nothing in it',
() async {
final cursor = ffi.cursorModel;
ffi.cursor.holdFetches = true;
_select(ffi, '1'); // not decoded here yet, so it is asked for
cursor.clear();
ffi.cursor.failFetches();
await _settle();
ffi.cursor.holdFetches = false;
_select(ffi, '1'); // the same id, now in the new session
await _settle();
expect(ffi.cursor.fetched, ['1', '1']);
});
test('a fetch that throws at once tells the listeners after the build',
() async {
final cursor = ffi.cursorModel;
await _feed(ffi, '1');
ffi.cursor.throwAtOnce = true;
cursor.id = '2';
var notified = 0;
cursor.addListener(() => notified++);
cursor.restorePixels('2');
expect(notified, 0, reason: 'a build may be what asked');
await _settle();
expect(notified, greaterThan(0));
expect(cursor.cache, isNull);
});
test('a raster still being made falls back to a shape, not the forbidden one',
() async {
final cursor = ffi.cursorModel;
for (var i = 0; preForbiddenCursor.cache == null && i < 100; i++) {
await _settle();
}
expect(preForbiddenCursor.cache, isNotNull);
await _feed(ffi, '1', size: 32);
buildCursorOfCache(cursor, 1.0, cursor.cache);
await _settle();
await _feed(ffi, '2', size: 32);
final shown = _key(buildCursorOfCache(cursor, 1.0, cursor.cache));
await _settle();
buildCursorOfCache(cursor, 1.0, preForbiddenCursor.cache); // input disabled
_select(ffi, '1'); // its native cursor held its pixels, so they went
expect(_key(buildCursorOfCache(cursor, 0.5, cursor.cache)), shown);
});
group('a painted cursor', () {
void painted(String how) {
if (how == 'mobile') {
final was = isMobile;
isMobile = true;
addTearDown(() => isMobile = was);
} else {
ffi.cursor.showRemoteCursor = true;
}
}
for (final how in ['mobile', 'remote cursor shown']) {
test('keeps the images of two animations and the everyday set ($how)',
() async {
painted(how);
final cursor = ffi.cursorModel;
final shapes = [
for (var i = 0; i < 12; i++) 's$i',
for (var i = 0; i < 23; i++) 'wait$i',
for (var i = 0; i < 23; i++) 'progress$i',
];
for (var i = 0; i < shapes.length; i++) {
await _feed(ffi, shapes[i], seed: i);
}
for (final id in shapes) {
_select(ffi, id);
expect(cursor.shapeIds.last, id, reason: 'its image is at hand');
}
await _settle();
expect(ffi.cursor.fetched, isEmpty);
});
}
test('keeps the images used last, and decodes the others again', () async {
painted('mobile');
const max = CursorModel.kRecentShapes;
final cursor = ffi.cursorModel;
for (var i = 0; i <= max; i++) {
await _feed(ffi, '$i');
}
expect(cursor.shapeIds.length, max);
_select(ffi, '$max');
_select(ffi, '1');
await _settle();
expect(ffi.cursor.fetched, isEmpty, reason: 'shape 1 was used lately');
_select(ffi, '0');
expect(cursor.image, isNotNull, reason: 'shape 1 until 0 is back');
await _settle();
expect(ffi.cursor.fetched, ['0']);
expect(cursor.shapeIds.length, max);
});
test('keeps a shape that finished decoding after the peer moved on',
() async {
painted('mobile');
const max = CursorModel.kRecentShapes;
final cursor = ffi.cursorModel;
for (var i = 0; i <= max; i++) {
await _feed(ffi, '$i');
}
_select(ffi, '0');
cursor.image; // let go, so it is asked for
await Future<void>.delayed(Duration.zero);
_select(ffi, '1');
await _settle();
_select(ffi, '0');
cursor.image;
await _settle();
expect(ffi.cursor.fetched, ['0'], reason: 'the late one was kept');
expect(cursor.shapeIds.last, '0');
});
});
}
+6 -37
View File
@@ -108,17 +108,11 @@ void main() {
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null));
for (final scenario in [
((4, 64), 0.5, (2, 32)),
((64, 4), 0.5, (32, 2)),
((1, 64), 0.25, (1, 16)),
((8, 8), 0.5, (12, 12)),
((4, 64), 1.0, (4, 64)),
((1, 512), 10.0, (1, 512)),
]) {
test('native cursor size $scenario',
() => _checkSize(scenario, registrations));
}
test('native raster boundaries rebuild buffers and distinguish cache keys',
() => _checkRasterTransitions(registrations));
test('cursor resize limits preserve the last valid raster', () async {
for (final size in [(30, 20), (20, 30)]) {
await _checkResizeLimits(size, registrations);
@@ -132,16 +126,16 @@ void main() {
test('Windows peer cursor alpha survives resizing $pattern',
() => _checkWindowsPeerAlpha(pattern, registrations));
}
for (final style in [
kRemoteViewStyleOriginal,
kRemoteViewStyleAdaptive,
kRemoteViewStyleCustom
for (final (style, zoom) in [
(kRemoteViewStyleOriginal, false),
(kRemoteViewStyleAdaptive, false),
(kRemoteViewStyleAdaptive, true),
(kRemoteViewStyleCustom, false),
(kRemoteViewStyleCustom, true),
]) {
for (final zoom in [false, true]) {
testWidgets('$style zoom=$zoom follows the live DPR and peer scale',
(tester) => _checkView(tester, (style, zoom), registrations));
}
}
}
CursorData _data((int, int) size, {Offset hotspot = Offset.zero}) {
@@ -241,31 +235,6 @@ void _expectSize(Map<dynamic, dynamic> args, (int, int) expected) {
}
}
Future<void> _checkRasterTransitions(
List<Map<dynamic, dynamic>> registrations) async {
const delta = 3e-8;
const scaleAboveOne = (2.25 / 1.75) / 2.25 * 1.75;
final ffi = _FFI(_Canvas(kRemoteViewStyleAdaptive));
final cursor = _Cursor(_data((64, 64)), ffi);
addTearDown(() => _dispose(cursor));
addTearDown(ffi.canvasModel.dispose);
for (final (scale, expected) in [
(scaleAboveOne, (65, 65)),
(0.5 - delta, (32, 32)),
(0.5 + delta, (33, 33)),
(1.0, (64, 64)),
(scaleAboveOne, (65, 65)),
(1.0, (64, 64)),
]) {
buildCursorOfCache(cursor, scale, cursor.cache);
await Future<void>.delayed(Duration.zero);
final key = cursor.nativeKey(cursor.cache, scale);
_expectSize(
registrations.singleWhere((args) => args['name'] == key), expected);
}
expect(registrations.length, 4);
}
Future<void> _checkResizeLimits(
(int, int) size, List<Map<dynamic, dynamic>> registrations) async {
const maxSide = 512;
-72
View File
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_hbb/models/file_model.dart';
@@ -62,19 +61,6 @@ void main() {
expect(directory.path, '/fast');
});
test('a send failure fails and removes its registered task', () async {
final failure = StateError('send failed');
final fileFetcher = FileFetcher(
() => _sessionId,
readRemoteDirectory: (_, __, ___) => Future<void>.error(failure),
);
await expectLater(
fileFetcher.fetchDirectory('/failed', false, false),
throwsA(same(failure)),
);
expect(fileFetcher.hasPendingRemoteRead('/failed'), isFalse);
});
test('a resolved Home path completes the sole empty-path request', () async {
final sent = <_SentRead>[];
final fileFetcher = FileFetcher(
@@ -107,35 +93,6 @@ void main() {
expect(controller.directory.value.path, '/home/user');
});
test('an exact path response is not taken by a pending Home request',
() async {
final sent = <_SentRead>[];
final fileFetcher = FileFetcher(
() => _sessionId,
readRemoteDirectory: (_, path, includeHidden) async {
sent.add(_SentRead(path, includeHidden));
},
);
final home = fileFetcher.fetchDirectory('', false, false);
final regular = fileFetcher.fetchDirectory('/regular', false, false);
await Future<void>.delayed(Duration.zero);
var homeCompleted = false;
home.then<void>((_) => homeCompleted = true);
fileFetcher.tryCompleteTask(_directoryJson('/unmatched'), 'false');
await Future<void>.delayed(Duration.zero);
expect(homeCompleted, isFalse);
fileFetcher.tryCompleteTask(_directoryJson('/regular'), 'false');
expect((await regular).path, '/regular');
await Future<void>.delayed(Duration.zero);
expect(homeCompleted, isFalse);
fileFetcher.tryCompleteTask(_directoryJson('/home/user'), 'false');
expect((await home).path, '/home/user');
expect(sent.map((request) => request.path), ['', '/regular']);
});
test('a read error completes the sole pending request', () async {
final fileFetcher = FileFetcher(
() => _sessionId,
@@ -230,35 +187,6 @@ void main() {
expect((await replacement).path, '/same');
});
test('a late dispatch failure cannot remove a replacement task', () async {
final dispatches = <Completer<void>>[];
final fileFetcher = FileFetcher(
() => _sessionId,
readRemoteDirectory: (_, __, ___) {
final dispatch = Completer<void>();
dispatches.add(dispatch);
return dispatch.future;
},
);
final first = fileFetcher.fetchDirectory('/same', false, false);
await Future<void>.delayed(Duration.zero);
final firstError = expectLater(first, throwsA(isA<StateError>()));
fileFetcher.beginRemoteSession();
await firstError;
final replacement = fileFetcher.fetchDirectory('/same', false, false);
await Future<void>.delayed(Duration.zero);
expect(dispatches, hasLength(2));
dispatches.first.completeError(StateError('late dispatch failure'));
await Future<void>.delayed(Duration.zero);
expect(fileFetcher.hasPendingRemoteRead('/same'), isTrue);
fileFetcher.tryCompleteTask(_directoryJson('/same'), 'false');
expect((await replacement).path, '/same');
dispatches.last.complete();
await Future<void>.delayed(Duration.zero);
});
test('navigation ignores stale directory responses', () async {
final fileFetcher = FileFetcher(
() => _sessionId,
+1 -319
View File
@@ -4,19 +4,6 @@ import 'package:flutter_hbb/models/input_modifier_utils.dart';
void main() {
group('shouldReleaseStaleMobileShift', () {
test('does not release when cached shift is already false', () {
expect(
shouldReleaseStaleMobileShift(
isMobile: true,
cachedShiftPressed: false,
actualShiftPressed: false,
logicalKey: LogicalKeyboardKey.keyD,
hasTrackedShiftKeyDown: true,
),
isFalse,
);
});
test('releases one-shot mobile shift after a text key', () {
expect(
shouldReleaseStaleMobileShift(
@@ -30,20 +17,6 @@ void main() {
);
});
test('does not release manually toggled shift without tracked key down',
() {
expect(
shouldReleaseStaleMobileShift(
isMobile: true,
cachedShiftPressed: true,
actualShiftPressed: false,
logicalKey: LogicalKeyboardKey.keyD,
hasTrackedShiftKeyDown: false,
),
isFalse,
);
});
test('does not release when shift is still physically pressed', () {
expect(
shouldReleaseStaleMobileShift(
@@ -56,84 +29,9 @@ void main() {
isFalse,
);
});
test('does not release on non-mobile platforms', () {
expect(
shouldReleaseStaleMobileShift(
isMobile: false,
cachedShiftPressed: true,
actualShiftPressed: false,
logicalKey: LogicalKeyboardKey.keyD,
hasTrackedShiftKeyDown: true,
),
isFalse,
);
});
test('releases on enter key', () {
expect(
shouldReleaseStaleMobileShift(
isMobile: true,
cachedShiftPressed: true,
actualShiftPressed: false,
logicalKey: LogicalKeyboardKey.enter,
hasTrackedShiftKeyDown: true,
),
isTrue,
);
});
test('releases on arrow key', () {
expect(
shouldReleaseStaleMobileShift(
isMobile: true,
cachedShiftPressed: true,
actualShiftPressed: false,
logicalKey: LogicalKeyboardKey.arrowLeft,
hasTrackedShiftKeyDown: true,
),
isTrue,
);
});
test('does not release on modifier events', () {
expect(
shouldReleaseStaleMobileShift(
isMobile: true,
cachedShiftPressed: true,
actualShiftPressed: false,
logicalKey: LogicalKeyboardKey.shiftLeft,
hasTrackedShiftKeyDown: true,
),
isFalse,
);
});
test('does not release on shiftRight modifier events', () {
expect(
shouldReleaseStaleMobileShift(
isMobile: true,
cachedShiftPressed: true,
actualShiftPressed: false,
logicalKey: LogicalKeyboardKey.shiftRight,
hasTrackedShiftKeyDown: true,
),
isFalse,
);
});
});
group('shouldApplyTerminalInputModifiers', () {
test('accepts ordinary single-character keyboard input', () {
expect(shouldApplyTerminalInputModifiers('a'), isTrue);
expect(shouldApplyTerminalInputModifiers(' '), isTrue);
expect(shouldApplyTerminalInputModifiers('/'), isTrue);
});
test('accepts supplementary-plane single-character keyboard input', () {
expect(shouldApplyTerminalInputModifiers('😀'), isTrue);
});
test('rejects terminal control bytes and multi-character sequences', () {
for (final input in ['\x00', '\x03', '\t', '\n', '\r', '\x1B', '\x7F']) {
expect(
@@ -147,43 +45,6 @@ void main() {
});
group('applyTerminalInputModifiers', () {
test('keeps decomposed graphemes intact under Ctrl', () {
const decomposedEAcute = 'e\u0301';
expect(
applyTerminalInputModifiers(
decomposedEAcute,
ctrlLocked: true,
altLocked: false,
),
decomposedEAcute,
);
});
test('keeps non-ASCII graphemes intact under Ctrl', () {
for (final input in ['é', '😀']) {
expect(
applyTerminalInputModifiers(
input,
ctrlLocked: true,
altLocked: false,
),
input,
);
}
});
test('maps Ctrl underscore to unit separator', () {
expect(
applyTerminalInputModifiers(
'_',
ctrlLocked: true,
altLocked: false,
),
'\x1F',
);
});
test('maps the complete Ctrl symbol range', () {
const mappings = {
'[': '\x1B',
@@ -219,22 +80,6 @@ void main() {
});
});
group('terminalPastePayload', () {
test('wraps paste text when bracketed paste mode is active', () {
expect(
terminalPastePayload('d', bracketedPasteMode: true),
'\x1B[200~d\x1B[201~',
);
});
test('keeps a lone newline unchanged when bracketed paste is disabled', () {
expect(
terminalPastePayload('\n', bracketedPasteMode: false),
'\n',
);
});
});
group('prepareTerminalInputPayload', () {
test('normalizes a mobile keyboard Enter to carriage return', () {
expect(
@@ -278,26 +123,9 @@ void main() {
);
});
test('keeps large keyboard payloads unchanged when modifiers are inactive',
() {
final payload = 'd' * (1024 * 1024);
expect(
prepareTerminalInputPayload(
payload,
source: TerminalInputSource.keyboard,
isMobileOrWebMobile: false,
bracketedPasteMode: false,
ctrlLocked: false,
altLocked: false,
),
payload,
);
});
test('keeps decomposed graphemes intact with locked keyboard modifiers',
() {
const decomposedEAcute = 'e\u0301';
const decomposedEAcute = 'é';
expect(
prepareTerminalInputPayload(
@@ -312,20 +140,6 @@ void main() {
);
});
test('preserves a lone pasted newline when modifiers are locked', () {
expect(
prepareTerminalInputPayload(
'\n',
source: TerminalInputSource.paste,
isMobileOrWebMobile: true,
bracketedPasteMode: false,
ctrlLocked: true,
altLocked: true,
),
'\n',
);
});
test('wraps paste without applying locked modifiers', () {
expect(
prepareTerminalInputPayload(
@@ -373,25 +187,6 @@ void main() {
);
});
test(
'keeps default xterm paste behavior when virtual modifiers are inactive',
() {
expect(
shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: true,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: false,
),
isFalse,
);
});
test('handles Ctrl+V and Meta+V when a virtual modifier lock is active',
() {
expect(
@@ -423,95 +218,6 @@ void main() {
isTrue,
);
});
test('handles paste shortcut repeats while a virtual lock is active', () {
expect(
shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: false,
isKeyRepeat: true,
controlPressed: true,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: true,
),
isTrue,
);
});
test('ignores key-up and unmodified V events', () {
expect(
shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: false,
isKeyRepeat: false,
controlPressed: true,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: true,
),
isFalse,
);
expect(
shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: false,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: true,
),
isFalse,
);
});
test('ignores paste shortcuts with extra modifiers', () {
for (final state in [
(control: true, meta: false, alt: true, shift: false),
(control: true, meta: false, alt: false, shift: true),
(control: false, meta: true, alt: false, shift: true),
(control: true, meta: true, alt: false, shift: false),
]) {
expect(
shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyV,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: state.control,
metaPressed: state.meta,
altPressed: state.alt,
shiftPressed: state.shift,
modifierLockActive: true,
),
isFalse,
);
}
});
test('ignores non-V key events', () {
expect(
shouldHandleTerminalPasteShortcut(
platform: TargetPlatform.windows,
logicalKey: LogicalKeyboardKey.keyC,
isKeyDown: true,
isKeyRepeat: false,
controlPressed: true,
metaPressed: false,
altPressed: false,
shiftPressed: false,
modifierLockActive: true,
),
isFalse,
);
});
});
group('shouldClearTerminalModifiersWhenRow3Collapses', () {
@@ -526,29 +232,5 @@ void main() {
isTrue,
);
});
test('does not clear modifiers when row expands', () {
expect(
shouldClearTerminalModifiersWhenRow3Collapses(
wasExpanded: false,
willExpand: true,
ctrlLocked: true,
altLocked: true,
),
isFalse,
);
});
test('clears Alt state when expanded row is collapsed', () {
expect(
shouldClearTerminalModifiersWhenRow3Collapses(
wasExpanded: true,
willExpand: false,
ctrlLocked: false,
altLocked: true,
),
isTrue,
);
});
});
}
@@ -50,9 +50,7 @@ void main() {
await tester.pump();
}
for (final trackpad in [false, true]) {
final device = trackpad ? 'trackpad' : 'wheel';
testWidgets('alternate screen reports $device after main-screen history',
testWidgets('alternate screen reports trackpad after main-screen history',
(tester) async {
final view = await mount(tester);
terminal.write('\x1b[?1049h$_captureMouse');
@@ -60,45 +58,13 @@ void main() {
final render = view.renderTerminal;
final point = render.localToGlobal(
Offset(render.cellSize.width * 2.5, render.lineHeight * 1.5));
if (trackpad) {
await scrollTrackpad(tester, point, render.lineHeight * 3);
} else {
await tester.sendEventToBinding(PointerScrollEvent(
position: point,
scrollDelta: Offset(0, -render.lineHeight * 3),
));
await tester.pump();
}
expect(output, isNotEmpty);
expect(output, everyElement('\x1b[<64;3;2M'));
expect(view.widget.scrollController!.offset, 0);
expect(controller.selection, isNull);
}, variant: TargetPlatformVariant.only(TargetPlatform.macOS));
}
testWidgets('trackpad scrolling keeps subsequent drag coordinates aligned',
(tester) async {
final view = await mount(tester);
terminal.write('\x1b[?1049h$_captureMouse');
await tester.pump();
final render = view.renderTerminal;
final start = render.localToGlobal(
Offset(render.cellSize.width * 2.5, render.lineHeight * 1.5));
final end = render.localToGlobal(
Offset(render.cellSize.width * 6.5, render.lineHeight * 2.5));
await scrollTrackpad(tester, start, render.lineHeight * 3);
output.clear();
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.down(start);
await mouse.moveTo(end);
await mouse.up();
await tester.pump(kDoubleTapTimeout);
expect(output, ['\x1b[<0;3;2M', '\x1b[<32;7;3M', '\x1b[<0;7;3m']);
expect(controller.selection, isNull);
expect(controller.suspendedPointerInputs, isFalse);
}, variant: TargetPlatformVariant.only(TargetPlatform.macOS));
testWidgets('leaving alternate screen restores local trackpad scrollback',
(tester) async {
@@ -17,24 +17,5 @@ void main() {
['Ctrl', 'Alt', '-', 'PgUp', 'PgDn'],
);
});
test('keeps two trailing Row3 placeholders for row alignment', () {
expect(terminalKeyboardRow3TrailingPlaceholderCount, 2);
});
test('keeps every expanded row aligned at 348dp', () {
final rowWidths = [
terminalKeyboardRowWidth(terminalKeyboardRow1Keys.length),
terminalKeyboardRowWidth(terminalKeyboardRow2Keys.length + 1),
terminalKeyboardRowWidth(
terminalKeyboardRow3Keys.length +
terminalKeyboardRow3TrailingPlaceholderCount,
),
];
expect(terminalKeyboardKeyWidth, 48);
expect(terminalKeyboardKeySpacing, 2);
expect(rowWidths, everyElement(348));
});
});
}
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -14,42 +12,6 @@ class _FakeFFI implements FFI {
}
void main() {
test('ignores paste that completes after the terminal model is disposed',
() async {
final model = TerminalModel(_FakeFFI());
final delayedClipboardText = Completer<String>();
// This mirrors Ctrl/Cmd+V: clipboard access starts first, then the page and
// model are disposed before the asynchronous read supplies its text.
final paste = delayedClipboardText.future.then(model.pasteText);
model.dispose();
delayedClipboardText.complete('late clipboard text');
await paste;
expect(model.debugBufferedInputCount, 0);
});
test('ignores terminal text input after the terminal model is disposed', () {
final model = TerminalModel(_FakeFFI());
var checkedCtrlLock = false;
var clearedCtrlLock = false;
model.isCtrlLocked = () {
checkedCtrlLock = true;
return true;
};
model.clearCtrlLock = () {
clearedCtrlLock = true;
};
model.dispose();
model.terminal.textInput('d');
expect(checkedCtrlLock, isFalse);
expect(clearedCtrlLock, isFalse);
expect(model.debugBufferedInputCount, 0);
});
test('builds its terminal with the wheel button fix', () {
final model = TerminalModel(_FakeFFI());
addTearDown(model.dispose);
+1 -95
View File
@@ -71,10 +71,9 @@ void main() {
String? report(
TerminalMouseButton button, [
TerminalMouseButtonState state = TerminalMouseButtonState.down,
CellOffset position = const CellOffset(10, 5),
]) {
output.clear();
terminal.mouseInput(button, state, position);
terminal.mouseInput(button, state, const CellOffset(10, 5));
return output.isEmpty ? null : output.single;
}
@@ -102,40 +101,6 @@ void main() {
);
});
test('reports utf-encoding wheel buttons beyond the normal-mode range', () {
terminal.write('\x1b[?1000h\x1b[?1005h');
expect(
report(
TerminalMouseButton.wheelDown,
TerminalMouseButtonState.down,
const CellOffset(400, 300),
),
'\x1b[M${String.fromCharCode(32 + 65)}'
'${String.fromCharCode(32 + 401)}${String.fromCharCode(32 + 301)}',
);
});
test('reports urxvt-encoding wheel buttons shifted by 32', () {
terminal.write('\x1b[?1000h\x1b[?1015h');
expect(report(TerminalMouseButton.wheelUp), '\x1b[96;11;6M');
expect(report(TerminalMouseButton.wheelDown), '\x1b[97;11;6M');
});
test('sends a null byte for coordinates past the encoding limit', () {
terminal.write('\x1b[?1000h');
expect(
report(
TerminalMouseButton.wheelUp,
TerminalMouseButtonState.down,
const CellOffset(300, 300),
),
'\x1b[M${String.fromCharCode(32 + 64)}\x00\x00',
);
});
test('leaves non-wheel buttons to the upstream handler', () {
terminal.write('\x1b[?1000h\x1b[?1006h');
@@ -147,27 +112,6 @@ void main() {
);
});
test('stays silent when the peer has not enabled mouse reporting', () {
expect(report(TerminalMouseButton.wheelDown), isNull);
expect(report(TerminalMouseButton.left), isNull);
});
test('stays silent for the wheel in click-only mode', () {
terminal.write('\x1b[?9h\x1b[?1006h');
expect(report(TerminalMouseButton.wheelDown), isNull);
expect(report(TerminalMouseButton.left), '\x1b[<0;11;6M');
});
test('does not report wheel button releases', () {
terminal.write('\x1b[?1000h\x1b[?1006h');
expect(
report(TerminalMouseButton.wheelDown, TerminalMouseButtonState.up),
isNull,
);
});
testWidgets('dragging below scrolls and extends selection', (tester) async {
final controller = TerminalController();
_writeLines(terminal, 80);
@@ -258,42 +202,4 @@ void main() {
expect(output.last, '\x1b[<0;${endCell.x + 1};${endCell.y + 1}m');
expect(controller.suspendedPointerInputs, isFalse);
});
testWidgets('tmux drag stays suppressed after mouse mode is disabled',
(tester) async {
final controller = TerminalController();
terminal.write('\x1b[?1049h\x1b[?1002h\x1b[?1006hword');
await tester.pumpWidget(_terminalHarness(terminal, controller));
final renderTerminal = tester
.state<TerminalViewState>(find.byType(TerminalView))
.renderTerminal;
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
final start = renderTerminal.localToGlobal(const Offset(40, 40));
final end = renderTerminal.localToGlobal(const Offset(240, 80));
await mouse.down(start);
output.clear();
terminal.write('\x1b[?1002l');
await mouse.moveTo(end);
expect(output, isEmpty);
expect(controller.selection, isNull);
expect(controller.suspendedPointerInputs, isTrue);
terminal.write('\x1b[?1002h');
await mouse.moveTo(start);
expect(output, isEmpty);
expect(controller.selection, isNull);
await mouse.up();
expect(output, isEmpty);
expect(controller.suspendedPointerInputs, isFalse);
await mouse.down(start);
output.clear();
terminal.write('\x1b[?1002l');
await mouse.up();
await tester.pump(kDoubleTapTimeout);
expect(output, isEmpty);
expect(controller.selection, isNull);
expect(controller.suspendedPointerInputs, isFalse);
});
}
-62
View File
@@ -1677,15 +1677,6 @@ mod tests {
assert_err_contains(err, "empty file name");
}
#[test]
fn set_files_rejects_null_byte_name() {
let mut job = new_validation_job(103);
let err = job
.set_files(vec![new_file_entry("bad\0name.txt")])
.expect_err("null byte in file name must be rejected");
assert_err_contains(err, "null bytes");
}
#[test]
fn set_files_rejects_mixed_entries_when_one_is_traversal() {
let mut job = new_validation_job(104);
@@ -1708,39 +1699,6 @@ mod tests {
assert_err_contains(err, "absolute path");
}
#[cfg(not(windows))]
#[test]
fn set_files_allows_backslash_prefixed_name_on_unix() {
let mut job = new_validation_job(105);
assert!(job
.set_files(vec![new_file_entry("\\\\server\\share\\payload.txt")])
.is_ok());
}
#[test]
fn remove_file_rejects_empty_path() {
let err = remove_file("").expect_err("empty file path must be rejected");
assert_err_contains(err, "cannot be empty");
}
#[test]
fn remove_file_rejects_null_byte_path() {
let err = remove_file("bad\0path").expect_err("null byte path must be rejected");
assert_err_contains(err, "null bytes");
}
#[test]
fn create_dir_rejects_empty_path() {
let err = create_dir("").expect_err("empty directory path must be rejected");
assert_err_contains(err, "cannot be empty");
}
#[test]
fn create_dir_rejects_null_byte_path() {
let err = create_dir("bad\0path").expect_err("null byte path must be rejected");
assert_err_contains(err, "null bytes");
}
#[test]
fn rename_file_rejects_invalid_new_name() {
let tmp_root = TestTempDir::new("rustdesk_rename_invalid");
@@ -1790,24 +1748,4 @@ mod tests {
assert!(!src.exists());
assert!(dst.exists());
}
#[cfg(windows)]
#[test]
fn set_files_rejects_windows_drive_absolute_path() {
let mut job = new_validation_job(106);
let err = job
.set_files(vec![new_file_entry("C:\\Windows\\Temp\\payload.txt")])
.expect_err("drive-letter absolute path must be rejected");
assert_err_contains(err, "absolute path");
}
#[cfg(windows)]
#[test]
fn set_files_rejects_windows_verbatim_drive_absolute_path() {
let mut job = new_validation_job(1061);
let err = job
.set_files(vec![new_file_entry(r"\\?\C:\Windows\Temp\x.txt")])
.expect_err("verbatim drive absolute path must be rejected");
assert_err_contains(err, "absolute path");
}
}
-56
View File
@@ -515,48 +515,6 @@ pub fn get_home_dir_trusted() -> Option<PathBuf> {
mod tests {
use super::*;
#[test]
fn test_transform_degrees_maps_all_eight_variants() {
use sctk::reexports::client::protocol::wl_output::Transform;
// Flipped variants report their rotation: the frame still needs that turn to read
// upright, and the mirror half has no producer among desktop compositors to test.
for (t, deg) in [
(Transform::Normal, 0),
(Transform::_90, 90),
(Transform::_180, 180),
(Transform::_270, 270),
(Transform::Flipped, 0),
(Transform::Flipped90, 90),
(Transform::Flipped180, 180),
(Transform::Flipped270, 270),
] {
assert_eq!(transform_degrees(t), deg, "{t:?}");
}
}
#[test]
fn test_display_info_without_transform_defaults_to_zero() {
// A snapshot serialized by an older probe child carries no transform field; it must
// deserialize with 0 rather than fail, or a greeter-side child update becomes a
// lockstep upgrade.
let old = r#"{"name":"HDMI-1","x":0,"y":0,"width":1920,"height":1080,"logical_size":null,"refresh_rate":60}"#;
let info: WaylandDisplayInfo = serde_json::from_str(old).unwrap();
assert_eq!(info.transform, 0);
let roundtrip: WaylandDisplayInfo =
serde_json::from_str(&serde_json::to_string(&info).unwrap()).unwrap();
assert_eq!(roundtrip.transform, 0);
}
#[test]
fn test_run_cmds_trim_newline() {
assert_eq!(run_cmds_trim_newline("echo -n 123").unwrap(), "123");
assert_eq!(run_cmds_trim_newline("echo 123").unwrap(), "123");
assert_eq!(
run_cmds_trim_newline("whoami").unwrap() + "\n",
run_cmds("whoami").unwrap()
);
}
/// Test get_home_dir_trusted: returns valid path and ignores HOME env var
#[test]
fn test_get_home_dir_trusted() {
@@ -584,20 +542,6 @@ mod tests {
}
}
/// Test shell_quote with normal strings
#[test]
fn test_shell_quote_normal() {
assert_eq!(shell_quote("hello"), "'hello'");
assert_eq!(shell_quote("/home/user"), "'/home/user'");
}
/// Test shell_quote with spaces
#[test]
fn test_shell_quote_spaces() {
assert_eq!(shell_quote("/home/my user/file"), "'/home/my user/file'");
assert_eq!(shell_quote("path with spaces"), "'path with spaces'");
}
/// Test shell_quote with single quotes (the tricky case)
#[test]
fn test_shell_quote_single_quotes() {
-8
View File
@@ -288,11 +288,3 @@ fn send_data_to_all(data: ClipboardFile) {
allow_err!(msg_channel.sender.send(data.clone()));
}
}
#[cfg(test)]
mod tests {
// #[test]
// fn test_cliprdr_run() {
// super::cliprdr_run();
// }
}
@@ -244,8 +244,6 @@ mod tests {
const ATTRIBUTES_OFFSET: usize = PDU_HEADER_SIZE + 36;
const LAST_WRITE_TIME_OFFSET: usize = PDU_HEADER_SIZE + 56;
const NAME_OFFSET: usize = PDU_HEADER_SIZE + 72;
const FILE_NAME_CODE_UNITS: usize = 260;
const INVALID_UTF16_UNIT: u16 = 0xdc00;
const FILE_ATTRIBUTE_NORMAL: u32 = 0x80;
fn descriptor_pdu(name: &str) -> Vec<u8> {
@@ -291,18 +289,6 @@ mod tests {
assert_eq!(files[0].name, PathBuf::from("folder/nested/file.txt"));
}
#[test]
fn ignores_data_after_null_terminator() {
let name = "file.txt";
let mut pdu = descriptor_pdu(name);
let padding_offset = NAME_OFFSET + (name.encode_utf16().count() + 1) * size_of::<u16>();
pdu[padding_offset..padding_offset + size_of::<u16>()]
.copy_from_slice(&INVALID_UTF16_UNIT.to_le_bytes());
let files = FileDescription::parse_file_descriptors(pdu, 0).unwrap();
assert_eq!(files[0].name, PathBuf::from("file.txt"));
}
#[test]
fn file_list_id_is_fnv1a() {
// Reference vectors; the Windows side computes the same id in C.
@@ -319,15 +305,6 @@ mod tests {
assert_eq!(files[0].clip_data_id, id);
}
#[test]
fn rejects_non_terminated_file_name() {
let name = "a".repeat(FILE_NAME_CODE_UNITS);
assert!(matches!(
parse_name(&name),
Err(CliprdrError::InvalidRequest { .. })
));
}
fn parse_perm(attributes: u32) -> u16 {
let mut pdu = descriptor_pdu("folder");
pdu[ATTRIBUTES_OFFSET..ATTRIBUTES_OFFSET + size_of::<u32>()]
@@ -966,35 +966,6 @@ mod fuse_test {
}
}
fn build_single_file(prefix: &str) {
let raw_name = "simple_test_file.txt";
let f_name = if prefix == "" {
raw_name.to_string()
} else {
prefix.to_string() + "/" + raw_name
};
let desc = desc_gen(&f_name, FileType::File);
let tree = FuseNode::build_tree(vec![desc]).unwrap();
assert_eq!(tree.len(), 2);
assert_eq!(tree[0].name, "/");
assert_eq!(tree[0].children, vec![2]);
assert_eq!(tree[1].name, raw_name);
assert_eq!(tree[1].index, 0);
assert_eq!(tree[1].attributes.kind, FileType::File);
}
#[test]
fn test_parse_single() {
build_single_file("");
build_single_file("/");
build_single_file("test");
build_single_file("/test");
build_single_file("🗂");
build_single_file("/🗂");
}
#[test]
fn test_parse_tree() {
build_tree("");
+1 -41
View File
@@ -322,7 +322,7 @@ pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result<Vec<LocalFile>, C
mod file_list_test {
use std::{
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
sync::atomic::AtomicU64,
time::{Duration, SystemTime},
};
@@ -466,32 +466,6 @@ mod file_list_test {
Ok(())
}
#[test]
fn read_exact_at_reopens_after_read_failure() -> Result<(), Box<dyn std::error::Error>> {
let file_path = std::env::temp_dir().join(format!(
"rustdesk-clipboard-local-file-{}",
std::process::id()
));
std::fs::write(&file_path, b"")?;
let mut file = LocalFile::try_open(&std::env::temp_dir(), &file_path)?;
file.size = 1;
let mut buf = [0u8; 1];
assert!(file.read_exact_at(&mut buf, 0).is_err());
assert!(file.handle.is_none());
assert_eq!(file.offset.load(Ordering::Relaxed), 0);
std::fs::write(&file_path, [42u8])?;
file.read_exact_at(&mut buf, 0)?;
assert_eq!(buf, [42u8]);
assert!(file.handle.is_none());
std::fs::remove_file(file_path)?;
Ok(())
}
const LAST_WRITE_TIME_OFFSET: usize = 56;
fn file_with_last_write_time(last_write_time: SystemTime) -> LocalFile {
@@ -519,18 +493,4 @@ mod file_list_test {
assert_eq!(encoded_last_write_time(time)?, 116_444_736_000_000_000);
Ok(())
}
#[test]
fn last_write_time_round_trips() -> Result<(), CliprdrError> {
let file = file_with_last_write_time(
SystemTime::UNIX_EPOCH + Duration::new(1_700_000_000, 123_456_700),
);
let mut pdu = BytesMut::with_capacity(4 + FILE_DESCRIPTOR_SIZE);
pdu.put_u32_le(1);
pdu.put(file.as_bin()?.as_slice());
let parsed = FileDescription::parse_file_descriptors(pdu.to_vec(), 0)?;
assert_eq!(parsed[0].last_modified, file.last_write_time);
Ok(())
}
}
@@ -809,18 +809,6 @@ mod tests {
assert!(!Path::new(&task.progress.download_file_path).exists());
}
#[test]
fn finder_progress_rejects_unrepresentable_byte_count() {
let (_temp, target, _) = test_directories();
let file = file_description("large.bin", FileType::File, i64::MAX as u64 + 1);
let mut task = paste_task_handle(target.clone(), vec![file]);
let error = task.update_next(0).expect_err("byte count must not wrap");
assert!(matches!(&error, CliprdrError::InvalidRequest { .. }));
task.on_error(error);
assert!(task.progress.finder_progress.is_none());
assert!(std::fs::read_dir(target).unwrap().next().is_none());
}
#[test]
fn validates_all_paths_before_creating_files() {
let (_temp, target, outside) = test_directories();
@@ -866,18 +854,4 @@ mod tests {
assert!(!download_path.exists());
assert!(!outside.join("file.txt").exists());
}
#[test]
fn rejects_symlink_component_when_creating_directory() {
let (_temp, target, outside) = test_directories();
symlink(&outside, target.join("link")).unwrap();
let directory = file_description("link/escaped", FileType::Directory, 0);
let mut task = paste_task_handle(target, vec![directory]);
assert!(matches!(
task.update_next(0),
Err(CliprdrError::InvalidRequest { .. })
));
assert!(!outside.join("escaped").exists());
}
}
@@ -430,31 +430,3 @@ pub fn create_pasteboard_context() -> ResultType<Box<PasteboardContext>> {
context.init();
Ok(context)
}
#[cfg(test)]
mod tests {
#[test]
fn test_temp_files_count() {
let mut c = super::PasteboardContext::temp_files_count();
let mut created_files = vec![];
for _ in 0..10 {
let path = format!(
"/tmp/{}{}",
super::TEMP_FILE_PREFIX,
uuid::Uuid::new_v4().to_string()
);
if std::fs::File::create(&path).is_ok() {
created_files.push(path);
c += 1;
}
}
assert_eq!(c, super::PasteboardContext::temp_files_count());
// Clean up the created files.
for file in created_files {
std::fs::remove_file(&file).ok();
}
}
}
@@ -523,48 +523,6 @@ mod sig_test {
}
}
#[test]
fn fingerprint_missing_path_is_default() {
let tmp = TmpDir::new("missing");
let missing = path_str(&tmp.join("does_not_exist.bin"));
let sigs = fingerprint(&[missing]);
assert_eq!(sigs.len(), 1);
// A path that can't be stat'd -> default sig, which forces a rebuild.
assert_eq!(sigs[0], FileSig::default());
assert_eq!(sigs[0].mtime, None);
}
#[test]
fn fingerprint_detects_inplace_edit() {
let tmp = TmpDir::new("edit");
let file = tmp.join("a.bin");
fs::write(&file, b"small").unwrap();
let p = path_str(&file);
let before = fingerprint(&[p.clone()]);
// Same content, same path: fingerprint must be stable.
let again = fingerprint(&[p.clone()]);
assert_eq!(before, again);
assert_eq!(before[0].size, 5);
assert!(!before[0].is_dir);
// Edit in place so the file grows.
fs::write(&file, b"much larger contents than before").unwrap();
let after = fingerprint(&[p]);
assert_ne!(before, after);
assert!(after[0].size > before[0].size);
}
#[test]
fn fingerprint_flags_directory() {
let tmp = TmpDir::new("dir");
let sub = tmp.join("subdir");
fs::create_dir_all(&sub).unwrap();
let sigs = fingerprint(&[path_str(&sub)]);
assert_eq!(sigs.len(), 1);
assert!(sigs[0].is_dir);
}
#[test]
fn recopy_after_edit_refreshes_cached_size() {
let tmp = TmpDir::new("recopy");
@@ -597,37 +555,6 @@ mod sig_test {
clear_files(); // leave the global clean for other tests
}
#[test]
fn range_request_huge_length_is_clamped() {
let tmp = TmpDir::new("range");
let file = tmp.join("data.bin");
fs::write(&file, b"0123456789").unwrap();
let files = vec![path_str(&file)];
let mut clip = ClipFiles::default();
clip.sync_files(&files, fingerprint(&files)).unwrap();
let file_idx = clip.first_file_index;
// offset + length used to wrap to 0, skip the clamp and ask for a u64::MAX buffer.
let resp = clip
.serve_file_contents(
0,
FileContentsRequest::Range {
stream_id: 0,
file_idx,
offset: 1,
length: u64::MAX,
},
)
.unwrap();
match resp {
ClipboardFile::FileContentsResponse { requested_data, .. } => {
assert_eq!(requested_data, b"123456789");
}
_ => panic!("unexpected response"),
}
}
#[test]
fn range_request_decodes_wire_fields_as_u32() {
let tmp = TmpDir::new("decode");
@@ -756,172 +683,6 @@ mod sig_test {
clear_files();
}
#[test]
fn failed_recopy_keeps_the_current_list() {
let tmp = TmpDir::new("failed_recopy");
let first = tmp.join("first.bin");
fs::write(&first, b"AAAAAAAA").unwrap();
let missing = tmp.join("missing.bin");
let _guard = lock_clip_files();
clear_files();
sync_files(&[path_str(&first)]).unwrap();
let pdu = get_file_list_pdu(1);
assert!(sync_files(&[path_str(&missing)]).is_err());
assert_eq!(get_file_list_pdu(1), pdu);
clear_files();
}
fn file_index(path: &PathBuf) -> i32 {
let files = CLIP_FILES.lock();
files
.file_list
.iter()
.position(|f| &f.path == path)
.unwrap() as i32
}
// Replaces `path` the way editors save: write aside, then rename over it.
fn replace_atomically(path: &PathBuf, data: &[u8]) {
let aside = path.with_extension("new");
fs::write(&aside, data).unwrap();
fs::rename(&aside, path).unwrap();
}
#[test]
fn retired_list_closes_only_files_not_being_read() {
let tmp = TmpDir::new("retired_handles");
let first = tmp.join("first.bin");
fs::write(&first, b"AAAABBBB").unwrap();
let next = tmp.join("next.bin");
fs::write(&next, b"CCCCDDDD").unwrap();
let other = tmp.join("other.bin");
fs::write(&other, b"EEEEEEEE").unwrap();
let _guard = lock_clip_files();
clear_files();
sync_files(&[path_str(&first), path_str(&next)]).unwrap();
let id = file_list_id(&get_file_list_pdu(1));
let (first_idx, next_idx) = (file_index(&first), file_index(&next));
let read =
|idx, offset| range_data(read_file_contents(1, 7, idx, 0x2, offset, 0, 4, Some(id)));
// Leaves `first` open at offset 4, and preloads `next`.
assert_eq!(read(first_idx, 0), b"AAAA");
sync_files(&[path_str(&other)]).unwrap();
{
let retired = RETIRED_CLIP_FILES.lock();
let open = |idx: i32| retired[0].file_list[idx as usize].handle.is_some();
assert!(open(first_idx));
assert!(!open(next_idx));
}
assert_eq!(read(first_idx, 4), b"BBBB");
assert_eq!(read(next_idx, 0), b"CCCC");
clear_files();
}
#[test]
fn files_being_read_keep_their_content_when_their_paths_are_replaced() {
let tmp = TmpDir::new("replaced_paths");
let first = tmp.join("first.bin");
fs::write(&first, b"1111aaaa").unwrap();
let second = tmp.join("second.bin");
fs::write(&second, b"2222bbbb").unwrap();
let other = tmp.join("other.bin");
fs::write(&other, b"EEEEEEEE").unwrap();
let _guard = lock_clip_files();
clear_files();
sync_files(&[path_str(&first), path_str(&second)]).unwrap();
let id = file_list_id(&get_file_list_pdu(1));
let (first_idx, second_idx) = (file_index(&first), file_index(&second));
let read =
|idx, offset| range_data(read_file_contents(1, 7, idx, 0x2, offset, 0, 4, Some(id)));
// Two files part-way through at once.
assert_eq!(read(first_idx, 0), b"1111");
assert_eq!(read(second_idx, 0), b"2222");
sync_files(&[path_str(&other)]).unwrap();
replace_atomically(&first, b"3333cccc");
replace_atomically(&second, b"4444dddd");
assert_eq!(read(first_idx, 4), b"aaaa");
assert_eq!(read(second_idx, 4), b"bbbb");
clear_files();
}
#[test]
fn same_file_metadata_in_another_folder_is_another_list() {
let tmp = TmpDir::new("same_metadata");
let (dir_a, dir_b) = (tmp.join("a"), tmp.join("b"));
fs::create_dir_all(&dir_a).unwrap();
fs::create_dir_all(&dir_b).unwrap();
let (first, second) = (dir_a.join("report.bin"), dir_b.join("report.bin"));
fs::write(&first, b"AAAAAAAA").unwrap();
fs::write(&second, b"BBBBBBBB").unwrap();
// Same name, size and mtime: the descriptors differ only by the nonce.
let mtime = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
for path in [&first, &second] {
fs::File::options()
.write(true)
.open(path)
.unwrap()
.set_modified(mtime)
.unwrap();
}
let _guard = lock_clip_files();
clear_files();
sync_files(&[path_str(&first)]).unwrap();
let first_id = file_list_id(&get_file_list_pdu(1));
let idx = CLIP_FILES.lock().first_file_index as i32;
sync_files(&[path_str(&second)]).unwrap();
let second_id = file_list_id(&get_file_list_pdu(1));
assert_ne!(first_id, second_id);
let read = |id| range_data(read_file_contents(1, 7, idx, 0x2, 4, 0, 4, Some(id)));
assert_eq!(read(first_id), b"AAAA");
assert_eq!(read(second_id), b"BBBB");
clear_files();
}
#[test]
fn a_retired_list_refuses_a_file_replaced_before_its_read() {
let tmp = TmpDir::new("replaced_unread");
let first = tmp.join("first.bin");
fs::write(&first, b"AAAAAAAA").unwrap();
let second = tmp.join("second.bin");
fs::write(&second, b"BBBBBBBB").unwrap();
let other = tmp.join("other.bin");
fs::write(&other, b"EEEEEEEE").unwrap();
let _guard = lock_clip_files();
clear_files();
sync_files(&[path_str(&first), path_str(&second)]).unwrap();
let id = file_list_id(&get_file_list_pdu(1));
let (first_idx, second_idx) = (file_index(&first), file_index(&second));
let read = |idx, offset| read_file_contents(1, 7, idx, 0x2, offset, 0, 4, Some(id));
assert_eq!(range_data(read(first_idx, 0)), b"AAAA");
// Retiring closes `second`, which has not been read yet; it is then replaced.
sync_files(&[path_str(&other)]).unwrap();
replace_atomically(&second, b"CCCCCCCCCC");
assert!(matches!(
read(second_idx, 0).last(),
Some(Err(CliprdrError::InvalidRequest { .. }))
));
// The file part-way through is unaffected.
assert_eq!(range_data(read(first_idx, 4)), b"AAAA");
clear_files();
}
fn list_files(selection: &[&PathBuf]) -> ClipFiles {
let files: Vec<String> = selection.iter().map(|p| path_str(p)).collect();
let mut clip = ClipFiles::default();
@@ -975,23 +736,6 @@ mod sig_test {
assert_only_its_own_requests_fail(&mut clip, &second);
}
#[test]
fn an_unreadable_next_file_fails_only_its_own_requests() {
use std::os::unix::fs::PermissionsExt;
let tmp = TmpDir::new("next_unreadable");
let (first, second) = (tmp.join("first.bin"), tmp.join("second.bin"));
fs::write(&first, b"AAAAAAAA").unwrap();
fs::write(&second, b"BBBBBBBB").unwrap();
let mut clip = list_files(&[&first, &second]);
fs::set_permissions(&second, fs::Permissions::from_mode(0o000)).unwrap();
// Root opens it regardless of its mode.
if fs::File::open(&second).is_ok() {
return;
}
assert_only_its_own_requests_fail(&mut clip, &second);
}
#[test]
fn the_next_file_is_preloaded_past_a_directory() {
let tmp = TmpDir::new("next_preloaded");
-36
View File
@@ -146,39 +146,3 @@ fn tokenize(input: &str) -> Result<Vec<Token>, ParseError> {
Ok(tokens)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn success() {
assert_eq!(
tokenize("{{Hello World!}} {+CTRL}hi{-CTRL}"),
Ok(vec![
Token::Sequence("{Hello World!} ".into()),
Token::KeyDown(Key::Control),
Token::Sequence("hi".into()),
Token::KeyUp(Key::Control)
])
);
}
#[test]
fn unexpected_open() {
assert_eq!(tokenize("{hello{}world}"), Err(ParseError::UnexpectedOpen));
}
#[test]
fn unmatched_open() {
assert_eq!(
tokenize("{this is going to fail"),
Err(ParseError::UnmatchedOpen)
);
}
#[test]
fn unmatched_close() {
assert_eq!(
tokenize("{+CTRL}{{this}} is going to fail}"),
Err(ParseError::UnmatchedClose)
);
}
}
-25
View File
@@ -525,28 +525,3 @@ impl fmt::Debug for Enigo {
write!(f, "Enigo")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_key_state() {
let mut enigo = Enigo::new();
let keys = [Key::CapsLock, Key::NumLock];
for k in keys.iter() {
enigo.key_click(k.clone());
let a = enigo.get_key_state(k.clone());
enigo.key_click(k.clone());
let b = enigo.get_key_state(k.clone());
assert!(a != b);
}
let keys = [Key::Control, Key::Alt, Key::Shift];
for k in keys.iter() {
enigo.key_down(k.clone()).ok();
let a = enigo.get_key_state(k.clone());
enigo.key_up(k.clone());
let b = enigo.get_key_state(k.clone());
assert!(a != b);
}
}
}
-7
View File
@@ -391,13 +391,6 @@ fn convert_to_tfc_key(key: Key) -> Option<TFC_Key> {
Some(key)
}
#[test]
fn test_key_seq() {
// Get 6^ in French.
let mut en = Enigo::new();
en.key_sequence("^^");
}
/// Both directions: the failure is silent, so a one-directional test passes against the bug.
#[test]
fn test_custom_mouse_dispatch_follows_is_x11() {
-50
View File
@@ -279,26 +279,6 @@ mod tests {
.find(|file| normalize_path(&file.path) == normalize_path(path))
}
#[test]
fn parses_the_generate_py_layout() {
let (files, exe) = parse(blob(
&[("./rustdesk.exe", b"app"), ("./custom.txt", b"cfg")],
"./rustdesk.exe",
))
.unwrap();
assert_eq!(exe, "./rustdesk.exe");
assert_eq!(files.len(), 2);
assert_eq!(entry(&files, "./custom.txt").unwrap().raw, b"cfg");
}
#[test]
fn rejects_malformed_blobs() {
assert!(parse(b"".as_slice()).is_none());
assert!(parse(b"notrustd".as_slice()).is_none());
// Truncated mid-record rather than panicking on a slice out of range.
assert!(parse(b"rustdesk\x00\x00\x00\x40partial".as_slice()).is_none());
}
#[test]
fn distinguishes_an_absent_package_from_a_malformed_one() {
assert!(parse_package_blob(None).unwrap().0.is_empty());
@@ -372,34 +352,4 @@ mod tests {
);
assert_eq!(entry(&files, "./librustdesk.dll").unwrap().raw, b"core");
}
#[test]
fn package_paths_are_recorded_for_the_dropped_file_sweep() {
let package = parse(blob(
&[("./custom.txt", b"cfg"), ("./data/logo.png", b"img")],
"./acme.exe",
))
.unwrap();
let mut paths: Vec<String> = package.0.iter().map(|f| f.path.clone()).collect();
paths.sort();
assert_eq!(paths, vec!["./custom.txt", "./data/logo.png"]);
// Merging must not disturb them: the generic payload contributes none.
let embedded = parse(blob(&[("./librustdesk.dll", b"core")], "./rustdesk.exe")).unwrap();
let (files, _) = merge(embedded, package);
assert!(entry(&files, "./data/logo.png").is_some());
}
#[test]
fn matches_paths_across_separator_styles() {
// generate.py emits backslashes when it runs on Windows.
let embedded = parse(blob(&[(".\\rustdesk.exe", b"app")], ".\\rustdesk.exe")).unwrap();
let package = parse(blob(&[("./custom.txt", b"cfg")], "./acme.exe")).unwrap();
let (files, exe) = merge(embedded, package);
assert_eq!(exe, "./acme.exe");
assert!(entry(&files, "./acme.exe").is_some());
assert!(entry(&files, ".\\rustdesk.exe").is_none());
}
}
-17
View File
@@ -199,23 +199,6 @@ mod webrtc {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::raw::c_uint;
#[test]
fn tile_log2_uses_c_uint_and_rounds_up() {
let one_thread: c_uint = tile_log2(1);
let three_threads: c_uint = tile_log2(3);
let max_threads: c_uint = tile_log2(64);
assert_eq!(one_thread, 0);
assert_eq!(three_threads, 2);
assert_eq!(max_threads, 6);
}
}
}
impl EncoderApi for AomEncoder {
+1 -187
View File
@@ -795,47 +795,7 @@ impl Drop for DrmReader {
#[cfg(test)]
mod hotspot_provenance_tests {
use super::{cursor_id, hot_measured_from, provenance_code, provenance_is_news};
/// The log this makes observable is one line per CHANGE, not one per cursor read. A cursor is
/// read far more often than its provenance changes, and a per-sample line here would be the
/// same defect libdrmtap already had once.
#[test]
fn provenance_is_logged_once_and_then_only_on_a_change() {
let (none, guessed, measured) = (
provenance_code(None),
provenance_code(Some(false)),
provenance_code(Some(true)),
);
assert_ne!(none, guessed);
assert_ne!(guessed, measured);
assert_ne!(none, measured);
// First sample of a run: -1 is the unset sentinel, so anything is news.
assert!(provenance_is_news(-1, measured));
// The same state again is not.
assert!(!provenance_is_news(measured, measured));
assert!(!provenance_is_news(guessed, guessed));
// A real transition is.
assert!(provenance_is_news(guessed, measured));
assert!(provenance_is_news(measured, none));
}
/// The dedup key has to move when the provenance does, or the consumer is never told that the
/// same picture now means something different. This is the transition that produces it: the
/// properties become readable, the driver's answer is the corner, and the bitmap is unchanged.
#[test]
fn provenance_changes_the_cursor_identity() {
let guessed = cursor_id(0xabc, 24, 24, 0, 0, false);
let measured = cursor_id(0xabc, 24, 24, 0, 0, true);
assert_ne!(
guessed, measured,
"same pixels, same (0, 0), different meaning: the producer must not dedupe this away"
);
// And the pre-existing parts still count, so this did not trade one blind spot for another.
assert_ne!(cursor_id(0xabc, 24, 24, 0, 0, true), cursor_id(0xabc, 32, 24, 0, 0, true));
assert_ne!(cursor_id(0xabc, 24, 24, 0, 0, true), cursor_id(0xabc, 24, 24, 1, 0, true));
assert_ne!(cursor_id(0xabc, 24, 24, 0, 0, true), cursor_id(0xdef, 24, 24, 0, 0, true));
}
use super::hot_measured_from;
/// The case the whole entry point exists for, and the one the old heuristic got backwards: a
/// para-virtualized driver that really publishes the hotspot at the image's top-left corner.
@@ -845,25 +805,6 @@ mod hotspot_provenance_tests {
fn a_measured_zero_hotspot_is_measured() {
assert!(hot_measured_from(Some(true), 0, 0));
}
/// And the mirror: when the library says it was NOT measured, that is the answer even if the
/// coordinates happen to be non-zero. The old test would have trusted them.
#[test]
fn the_librarys_no_beats_the_coordinates() {
assert!(!hot_measured_from(Some(false), 12, 11));
assert!(!hot_measured_from(Some(false), 0, 0));
}
/// No answer available: either the symbol is absent (a library older than 0.5.6) or nothing
/// recorded one for this sample (a cursor read through an older privileged helper). Neither
/// is "it was a guess", so the pre-existing behaviour is kept rather than resolved either way
/// -- an older deployed .so must keep behaving as it used to.
#[test]
fn without_an_answer_the_old_heuristic_stands() {
assert!(!hot_measured_from(None, 0, 0));
assert!(hot_measured_from(None, 12, 11));
assert!(hot_measured_from(None, 0, 5));
}
}
#[cfg(test)]
@@ -924,45 +865,6 @@ mod hotspot_guess_tests {
assert_eq!(infer_hotspot(&px, w, h), (4, 4));
}
/// Each axis is decided by its OWN mirror. This shape is symmetric left-to-right and not
/// top-to-bottom, which is the single-direction resize family: the click point is centred
/// across the axis that mirrors and stays at the edge on the axis that does not.
#[test]
fn one_symmetric_axis_centres_that_axis_only() {
let (px, w, h) = sprite(&[
"....#....",
"...###...",
"..#####..",
".#######.",
"....#....",
"....#....",
"....#....",
"....#....",
"....#....",
]);
assert_eq!(infer_hotspot(&px, w, h), (4, 0));
}
/// A two-headed DIAGONAL arrow mirrors onto the other diagonal about either axis alone, so
/// neither axis reads as symmetric - and it is still a shape with no tip. The half turn is
/// what catches it.
#[test]
fn a_diagonal_two_headed_arrow_is_held_by_its_middle_too() {
let (px, w, h) = sprite(&[
"###......",
"##.......",
"#.#......",
"...#.....",
"....#....",
".....#...",
"......#.#",
".......##",
"......###",
]);
let (hx, hy) = infer_hotspot(&px, w, h);
assert_eq!((hx, hy), (4, 4), "a half turn maps this shape onto itself");
}
/// A single-direction resize cursor is an arrow plus the bar of the edge it resizes, and the
/// theme puts the click point on the BAR. The bar is the heavy end, and it is the only thing
/// that separates `e-resize` from `w-resize`: they are the same picture reflected, so a rule
@@ -992,25 +894,6 @@ mod hotspot_guess_tests {
assert_eq!(infer_hotspot(&px, w, h), (0, 3));
}
/// A corner resize cursor is a bracket hugging one corner, and it mirrors onto itself about a
/// DIAGONAL rather than about either axis, so neither per-axis test sees it. The click point
/// is the inner corner of the bracket, which is the heavy end on both axes.
///
/// A real one's box is usually not square, and a diagonal reflection only lines up inside a
/// square, so which corner of that square the box is placed against decides the answer:
/// `ne-resize` scores 0.62 anchored one way and 0.98 the other, and `nw-resize` is the
/// reverse. Both anchorings are therefore tried. That case cannot be built as a small
/// picture - a non-square box can never be exactly diagonal-symmetric - so it is covered by
/// the theme corpus instead, where dropping the second anchoring sends `ne-resize` back past
/// master.
#[test]
fn a_corner_bracket_is_held_by_its_inner_corner() {
let (px, w, h) = sprite(&[
".....##", ".....##", ".....##", ".....##", ".....##", "#######", "#######",
]);
assert_eq!(infer_hotspot(&px, w, h), (6, 6));
}
/// The elongation rule stays ahead of the mirror test, so a bar that is NOT symmetric still
/// keeps its centre. Nothing in the installed theme is shaped like this - its two I-beams are
/// both symmetric - but a custom cursor can be, and this is the rustdesk#16242 round-one fix.
@@ -1019,14 +902,6 @@ mod hotspot_guess_tests {
let (px, w, h) = sprite(&["##########.", "#####......", "##########."]);
assert_eq!(infer_hotspot(&px, w, h), (4, 1));
}
/// Nothing opaque is not a shape, and must not read as a box at the origin with a hotspot in
/// the middle of it.
#[test]
fn a_blank_sprite_has_no_hotspot_to_guess() {
let (px, w, h) = sprite(&["...", "...", "..."]);
assert_eq!(infer_hotspot(&px, w, h), (0, 0));
}
}
/// DRM_MODE_ROTATE_0: the `rotation` bitmask of a plane that turned nothing.
@@ -1050,8 +925,6 @@ fn plane_rotation_answer(rc: c_int, rotation: u32) -> Option<u32> {
#[cfg(test)]
mod plane_rotation_tests {
use super::*;
use std::os::fd::AsRawFd;
use std::sync::atomic::{AtomicI32, AtomicU32, Ordering};
#[test]
fn a_plane_without_the_property_turned_nothing() {
@@ -1062,65 +935,6 @@ mod plane_rotation_tests {
assert_eq!(plane_rotation_answer(-hbb_common::libc::EINVAL, 0), None);
assert_eq!(plane_rotation_answer(-hbb_common::libc::EIO, 7), None);
}
// What the plane answers whenever it is asked. The fake release turns it, the way a compositor
// may once the frame is out, so a read taken after the release or outside the grab sees 0x1.
static PLANE: AtomicU32 = AtomicU32::new(0x1);
static FD: AtomicI32 = AtomicI32::new(-1);
static PIXELS: [u8; 16] = [0; 16];
unsafe extern "C" fn plane_rotation(_: *mut drmtap_ctx, rotation: *mut u32) -> c_int {
*rotation = PLANE.load(Ordering::SeqCst);
0
}
unsafe extern "C" fn frame_release(_: *mut drmtap_ctx, _: *mut drmtap_frame_info) {
PLANE.store(0x1, Ordering::SeqCst);
}
unsafe extern "C" fn grab_mapped(_: *mut drmtap_ctx, f: *mut drmtap_frame_info) -> c_int {
(*f).data = PIXELS.as_ptr() as *mut _;
(*f).width = 2;
(*f).height = 2;
(*f).stride = 8;
(*f).format = DRM_FORMAT_XRGB8888;
0
}
unsafe extern "C" fn grab_desc(
_: *mut drmtap_ctx,
d: *mut drmtap_dmabuf_desc,
_: *mut drmtap_frame_info,
) -> c_int {
(*d).dma_buf_fd = FD.load(Ordering::SeqCst);
(*d).width = 2;
(*d).height = 2;
(*d).num_planes = 1;
(*d).pitches[0] = 8;
0
}
#[test]
fn a_frame_keeps_the_rotation_its_plane_had_when_it_was_grabbed() {
let lib: &'static DrmtapLib = Box::leak(Box::new(DrmtapLib::fake(
grab_mapped,
frame_release,
grab_desc,
plane_rotation,
)));
let mut r = DrmReader {
lib,
ctx: std::ptr::null_mut(),
buf: Vec::new(),
last_provenance: -1,
last_plane_rotation: None,
};
PLANE.store(0x4, Ordering::SeqCst);
r.grab().expect("the fake grab succeeds");
assert_eq!(r.plane_rotation(), Some(0x4), "cpu grab");
let (fd, _peer) = std::os::unix::net::UnixStream::pair().unwrap();
FD.store(fd.as_raw_fd(), Ordering::SeqCst);
PLANE.store(0x8, Ordering::SeqCst);
r.grab_desc().expect("the fake export succeeds");
assert_eq!(r.plane_rotation(), Some(0x8), "dma-buf export");
}
}
#[cfg(test)]
-41
View File
@@ -464,41 +464,6 @@ pub fn get() -> Option<&'static DrmtapLib> {
mod tests {
use super::{abi_accepted, DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, DRMTAP_MIN_MINOR_PATCH};
#[test]
fn abi_gate_rejects_a_library_from_before_the_split() {
// These are refused because their MINOR differs from the verified one, which is the only
// reason the gate needs. Naming the pre-split releases keeps the intent readable, but do
// not read this as the floor doing the work: see the test below.
for (minor, patch) in [(3, 3), (4, 0), (4, 8), (4, 9)] {
assert!(
!abi_accepted(DRMTAP_ABI_MAJOR, minor, patch),
"v0.{minor}.{patch} is not the verified minor and must be refused"
);
}
}
#[test]
fn the_patch_floor_is_currently_vacuous_and_that_is_deliberate() {
// With MIN_MINOR_PATCH.0 == DRMTAP_ABI_MINOR the floor can never reject anything: the
// minor equality already forces `(minor, patch) >= (minor, 0)`. It is kept because it is
// the mechanism that WOULD do the work the next time a floor lands mid-minor, as (4, 10)
// did for the split API. This test exists so nobody reads the pre-split test above as
// evidence that the floor is live -- if that ever matters, this assert is the tripwire.
let (floor_minor, floor_patch) = DRMTAP_MIN_MINOR_PATCH;
assert_eq!(
floor_minor, DRMTAP_ABI_MINOR,
"the floor is inside the verified minor; a floor in a DIFFERENT minor is unreachable"
);
if floor_patch == 0 {
assert!(
abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, 0),
"patch 0 of the verified minor must be accepted while the floor is 0"
);
} else {
assert!(!abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, floor_patch - 1));
}
}
#[test]
fn abi_gate_accepts_the_floor_and_later_patches_of_the_same_minor() {
let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH;
@@ -528,10 +493,4 @@ mod tests {
);
}
}
#[test]
fn abi_gate_rejects_another_major_in_both_directions() {
assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 0, 0));
assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 99, 99));
}
}
-35
View File
@@ -176,38 +176,3 @@ impl Drop for CapturerGDI {
}
}
}
#[cfg(test)]
mod tests {
use super::super::*;
use super::*;
#[test]
fn test() {
match Displays::new().unwrap().next() {
Some(d) => {
let w = d.width();
let h = d.height();
let c = CapturerGDI::new(d.name(), w, h).unwrap();
let mut data = Vec::new();
c.frame(&mut data).unwrap();
let mut bitflipped = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
let i = (w * 4 * y + 4 * x) as usize;
bitflipped.extend_from_slice(&[data[i + 2], data[i + 1], data[i], 255]);
}
}
repng::encode(
std::fs::File::create("gdi_screen.png").unwrap(),
d.width() as u32,
d.height() as u32,
&bitflipped,
)
.unwrap();
}
_ => {
assert!(false);
}
}
}
}
-14
View File
@@ -692,17 +692,3 @@ impl CapturerMag {
TRUE
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test() {
let mut capture_mag = CapturerMag::new((0, 0), 1920, 1080).unwrap();
capture_mag.exclude("", "RustDeskPrivacyWindow").unwrap();
std::thread::sleep(std::time::Duration::from_millis(1000 * 10));
let mut data = Vec::new();
capture_mag.frame(&mut data).unwrap();
println!("capture data len: {}", data.len());
}
}
+5 -152
View File
@@ -21,10 +21,10 @@ const COMMAND_TIMEOUT: Duration = Duration::from_millis(1000);
// drm builds only: an unnamed-endpoint failure there forks the probe child, and the pollers
// turn every few hundred milliseconds. Every other failure is one cheap in-process error.
#[cfg(any(test, feature = "drm"))]
#[cfg(feature = "drm")]
const FAILED_LOOKUP_BACKOFF: Duration = Duration::from_secs(5);
#[cfg(any(test, feature = "drm"))]
#[cfg(feature = "drm")]
static LAST_FAILED_LOOKUP: Mutex<Option<Instant>> = Mutex::new(None);
#[cfg(feature = "drm")]
@@ -183,8 +183,7 @@ fn get_primary_monitor() -> Option<String> {
.or_else(try_gdbus_primary)
}
// Pure, so the backoff policy is testable without a compositor.
#[cfg(any(test, feature = "drm"))]
#[cfg(feature = "drm")]
fn lookup_allowed(failed_at: Option<Instant>, now: Instant) -> bool {
failed_at.map_or(true, |at| {
now.saturating_duration_since(at) >= FAILED_LOOKUP_BACKOFF
@@ -302,6 +301,7 @@ pub fn clear_wayland_displays_cache() {
// every OTHER live capturer on a multi-display session. A capturer records this at build and
// treats a later bump as "the layout changed under me, rebuild" — the only trigger a rotation
// has, since it changes neither the CRTC mode nor the framebuffer size (rustdesk#15886).
#[cfg(feature = "drm")]
static SNAPSHOT_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Whether no snapshot has been cached: the signature of an enumeration that failed at session
@@ -311,7 +311,7 @@ pub fn wayland_snapshot_missing() -> bool {
DISPLAYS.lock().unwrap().is_none()
}
#[cfg(any(test, feature = "drm"))]
#[cfg(feature = "drm")]
pub fn bump_layout_generation() {
SNAPSHOT_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Release);
}
@@ -516,40 +516,6 @@ fn map_axis(v: i32, base_origin: i32, base_extent: i32, live_origin: i32, live_e
mod tests {
use super::*;
#[test]
fn test_lookup_backoff_boundaries() {
// Future `now`s sidestep Instant subtraction, which can panic near boot.
let failed_at = Instant::now();
assert!(lookup_allowed(None, failed_at));
assert!(!lookup_allowed(
Some(failed_at),
failed_at + FAILED_LOOKUP_BACKOFF / 2
));
assert!(lookup_allowed(
Some(failed_at),
failed_at + FAILED_LOOKUP_BACKOFF
));
}
#[test]
fn test_lookup_stamp_from_the_future_only_waits() {
// saturating_duration_since answers zero rather than underflowing.
let now = Instant::now();
assert!(!lookup_allowed(Some(now + FAILED_LOOKUP_BACKOFF), now));
}
#[test]
fn test_clear_keeps_the_failure_stamp() {
// The stamp describes the seat, not the cache: the ~1/s capturer rebuild loop clears,
// and dropping the stamp with it would defeat the backoff. The generation test also
// calls clear now; both only assert monotonic/unchanged state, so they can interleave.
*LAST_FAILED_LOOKUP.lock().unwrap() = Some(Instant::now());
clear_wayland_displays_cache();
let stamp = *LAST_FAILED_LOOKUP.lock().unwrap();
assert!(stamp.is_some());
*LAST_FAILED_LOOKUP.lock().unwrap() = None;
}
fn display(
x: i32,
y: i32,
@@ -569,11 +535,6 @@ mod tests {
}
}
#[test]
fn test_desktop_rect_empty() {
assert_eq!(desktop_rect_of(&[]), None);
}
#[test]
fn test_desktop_rect_single_display_uses_physical_size() {
let displays = [display(0, 0, 2880, 1800, Some((1859, 1162)))];
@@ -591,15 +552,6 @@ mod tests {
assert_eq!(desktop_rect_of(&displays), Some((0, 3779, 0, 2160)));
}
#[test]
fn test_desktop_rect_missing_logical_size_falls_back_to_physical() {
let displays = [
display(0, 0, 2560, 1440, None),
display(2560, 0, 2560, 1440, Some((2560, 1440))),
];
assert_eq!(desktop_rect_of(&displays), Some((0, 5120, 0, 1440)));
}
#[test]
fn a_single_rotated_display_swaps_the_uinput_rect() {
// Review finding 1 on rustdesk#15889: the single-display branch served the unrotated
@@ -611,31 +563,6 @@ mod tests {
assert_eq!((rects[0].w, rects[0].h), (1080, 1920));
}
#[test]
fn a_transform_flip_is_visible_to_the_drift_comparison() {
// Review finding 5: 0<->180 and 90<->270 leave every rect identical; the transform
// field is what lets `baseline != live` fire on them.
let mut a = display(0, 0, 1920, 1080, Some((1920, 1080)));
let mut b = a.clone();
a.transform = 90;
b.transform = 270;
assert_ne!(logical_rects_of(&[a.clone(), a.clone()]), logical_rects_of(&[b.clone(), b]));
}
#[test]
fn only_the_explicit_bump_moves_the_generation() {
// A cache clear must NOT bump: session inits clear too, and a bump there rebuilds
// every other live capturer (adversarial finding on the first version of this).
let before = SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire);
clear_wayland_displays_cache();
assert_eq!(
SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire),
before
);
bump_layout_generation();
assert!(SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire) > before);
}
fn rect(name: &str, x: i32, y: i32, w: i32, h: i32) -> DisplayRect {
DisplayRect {
name: name.to_owned(),
@@ -671,16 +598,6 @@ mod tests {
);
}
// A point on the rescaled display itself is squeezed to its new logical width.
#[test]
fn test_remap_scales_within_resized_display() {
let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)];
let live = [rect("DP-1", 0, 0, 2048, 1440), rect("DP-2", 2048, 0, 2560, 1440)];
// x=1280 across the 2560-wide baseline DP-1 -> proportionally across the 2048-wide
// live DP-1 (endpoint-preserving scale, so ~1px off the naive midpoint).
assert_eq!(remap_to_live_layout(1280, 500, &baseline, &live), (1023, 500));
}
// The far edge of the followed display stays reachable when it is enlarged, so hot
// corners keep working. Baseline DP-1 is 2048 wide, live DP-1 is 2560 wide; the
// client's last column (2047) must map to the live last column (2559), not 2558.
@@ -692,57 +609,6 @@ mod tests {
assert_eq!(remap_to_live_layout(0, 0, &baseline, &live), (0, 0));
}
// No drift: identical layouts map every point to itself.
#[test]
fn test_remap_identity_when_unchanged() {
let layout = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)];
assert_eq!(remap_to_live_layout(3000, 700, &layout, &layout), (3000, 700));
}
// Point outside every baseline display is left untouched.
#[test]
fn test_remap_point_outside_all_displays_unchanged() {
let baseline = [rect("DP-1", 0, 0, 2560, 1440)];
let live = [rect("DP-1", 0, 0, 2048, 1440)];
assert_eq!(remap_to_live_layout(9000, 9000, &baseline, &live), (9000, 9000));
}
// Matched display gone from the live layout (e.g. unplugged): leave the point be
// rather than mapping it somewhere wrong.
#[test]
fn test_remap_display_removed_unchanged() {
let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)];
let live = [rect("DP-1", 0, 0, 2560, 1440)];
assert_eq!(remap_to_live_layout(2600, 100, &baseline, &live), (2600, 100));
}
// Nameless compositor: fall back to index matching while the count is unchanged.
#[test]
fn test_remap_nameless_index_fallback() {
let baseline = [rect("", 0, 0, 2560, 1440), rect("", 2560, 0, 2560, 1440)];
let live = [rect("", 0, 0, 2048, 1440), rect("", 2048, 0, 2560, 1440)];
assert_eq!(remap_to_live_layout(2560, 0, &baseline, &live), (2048, 0));
}
// Nameless compositor with a changed count: cannot index-match safely, so no-op.
#[test]
fn test_remap_nameless_count_changed_unchanged() {
let baseline = [rect("", 0, 0, 2560, 1440), rect("", 2560, 0, 2560, 1440)];
let live = [rect("", 0, 0, 2048, 1440)];
assert_eq!(remap_to_live_layout(2560, 0, &baseline, &live), (2560, 0));
}
// A named display absent from the live layout, but the count is unchanged (e.g. a
// monitor was swapped for a different one at the same index): the index fallback is
// for nameless layouts only, so a named miss stays unchanged rather than mapping to
// whatever now occupies that index.
#[test]
fn test_remap_named_miss_equal_count_unchanged() {
let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)];
let live = [rect("DP-1", 0, 0, 2048, 1440), rect("HDMI-1", 2048, 0, 1920, 1080)];
assert_eq!(remap_to_live_layout(2600, 100, &baseline, &live), (2600, 100));
}
// A single display uses physical size in both baseline and live (scale reported as
// 1.0), so it never drifts and the remap is a no-op even across a rescale.
#[test]
@@ -753,17 +619,4 @@ mod tests {
vec![rect("", 0, 0, 2560, 1440)]
);
}
// Multiple displays use logical size, falling back to physical when absent.
#[test]
fn test_logical_rects_multi_display_uses_logical() {
let displays = [
display(0, 0, 2560, 1440, Some((2048, 1152))),
display(2048, 0, 1920, 1080, None),
];
assert_eq!(
logical_rects_of(&displays),
vec![rect("", 0, 0, 2048, 1152), rect("", 2048, 0, 1920, 1080)]
);
}
}
+1 -40
View File
@@ -1802,7 +1802,7 @@ fn sort_streams(
#[cfg(test)]
mod tests {
use super::{corrected_origin, reconciled_origin, stage_err, WaylandDisplayInfo};
use super::{corrected_origin, WaylandDisplayInfo};
fn output(x: i32, y: i32, mode: (i32, i32)) -> WaylandDisplayInfo {
WaylandDisplayInfo {
@@ -1867,43 +1867,4 @@ mod tests {
Some((2560, 0))
);
}
#[test]
fn a_cached_position_is_not_overridden_by_an_unmeasured_size() {
let outputs = [output(0, 0, (1440, 900)), output(1920, 0, (2880, 1800))];
// Restored from the cache, or sent by the portal: get_res() failed, so the size is the
// portal size, here the mode of the other output, and it moves nothing.
assert_eq!(
reconciled_origin((1920, 0), (1440, 900), false, &outputs),
None
);
// A measured size still moves a position that names no output of that size: xdph sends
// (0, 0) for every stream.
let pair = [output(0, 0, (1920, 1080)), output(1920, 0, (2880, 1800))];
assert_eq!(
reconciled_origin((0, 0), (2880, 1800), true, &pair),
Some((1920, 0))
);
}
#[test]
fn stage_err_keeps_the_detail_safe_for_a_placeholder() {
assert_eq!(
stage_err("start", "declined", ""),
"wl-stage:start:declined:"
);
// Braces of its own would break the placeholder lookup on the peer.
assert_eq!(
stage_err("create-session", "dbus", "org.freedesktop.{Error}"),
"wl-stage:create-session:dbus:org.freedesktop.Error"
);
assert_eq!(
stage_err("select-sources", "internal", "one\ntwo"),
"wl-stage:select-sources:internal:one two"
);
assert_eq!(
stage_err("start", "internal", &"x".repeat(300)),
format!("wl-stage:start:internal:{}", "x".repeat(200))
);
}
}
-3
View File
@@ -237,6 +237,3 @@ fn validate_input(input: &[f32], channels: usize) -> Result<(), AudioResamplerEr
#[cfg(all(test, not(all(feature = "use_samplerate", not(feature = "use_dasp")))))]
mod tests;
#[cfg(all(test, feature = "use_samplerate", not(feature = "use_dasp")))]
mod samplerate_tests;
-157
View File
@@ -1,157 +0,0 @@
use super::{AudioResampler, AudioResamplerConfig, AudioResamplerError, FixedFrameAudioResampler};
const INPUT_RATE: u32 = 44_100;
const OUTPUT_RATE: u32 = 48_000;
const CHANNELS: u16 = 2;
const INPUT_PACKET_FRAMES: usize = INPUT_RATE as usize / PACKETS_PER_SECOND;
const OUTPUT_PACKET_FRAMES: usize = OUTPUT_RATE as usize / PACKETS_PER_SECOND;
const PACKET_COUNT: usize = 20;
const PACKETS_PER_SECOND: usize = 100;
const MIN_CONTINUITY_PACKETS: usize = 2;
const TONE_FREQUENCY_HZ: f32 = 997.0;
const TONE_AMPLITUDE: f32 = 0.5;
const MAX_BOUNDARY_RESIDUAL: f32 = 0.02;
const INCOMPLETE_SAMPLE_COUNT: usize = 1;
const DOWNSAMPLE_RATE: u32 = 24_000;
const REJECTED_TONE_HZ: f64 = 18_000.0;
const MAX_ALIAS_RMS: f64 = 0.01;
const MIN_PASSBAND_RMS: f64 = 0.3;
fn stereo_tone(frames: usize) -> Vec<f32> {
(0..frames)
.flat_map(|frame| {
let phase =
std::f32::consts::TAU * TONE_FREQUENCY_HZ * frame as f32 / INPUT_RATE as f32;
let sample = TONE_AMPLITUDE * phase.sin();
[sample, sample]
})
.collect()
}
fn maximum_boundary_residual(packets: &[Vec<f32>]) -> f32 {
packets.windows(2).fold(0.0, |maximum, pair| {
let previous = &pair[0];
let current = &pair[1];
let last = previous.len() - CHANNELS as usize;
let penultimate = last - CHANNELS as usize;
(0..CHANNELS as usize).fold(maximum, |maximum, channel| {
let predicted = previous[last + channel]
+ (previous[last + channel] - previous[penultimate + channel]);
maximum.max((current[channel] - predicted).abs())
})
})
}
fn stereo_config() -> AudioResamplerConfig {
AudioResamplerConfig {
input_rate: INPUT_RATE,
output_rate: OUTPUT_RATE,
channels: CHANNELS,
}
}
#[test]
fn moving_capture_resampler_preserves_pending_audio() {
let input = stereo_tone(INPUT_PACKET_FRAMES * PACKET_COUNT);
let packet_samples = INPUT_PACKET_FRAMES * CHANNELS as usize;
let mut expected_resampler =
FixedFrameAudioResampler::new(stereo_config(), OUTPUT_PACKET_FRAMES).unwrap();
let expected: Vec<_> = input
.chunks(packet_samples)
.flat_map(|packet| expected_resampler.process(packet).unwrap())
.collect();
let mut moved_resampler =
FixedFrameAudioResampler::new(stereo_config(), OUTPUT_PACKET_FRAMES).unwrap();
let mut output = moved_resampler.process(&input[..packet_samples]).unwrap();
let remaining = std::thread::spawn(move || {
input[packet_samples..]
.chunks(packet_samples)
.flat_map(|packet| moved_resampler.process(packet).unwrap())
.collect::<Vec<_>>()
})
.join()
.unwrap();
output.extend(remaining);
assert!(output.len() >= MIN_CONTINUITY_PACKETS);
assert!(output
.iter()
.all(|packet| packet.len() == OUTPUT_PACKET_FRAMES * CHANNELS as usize));
assert!(maximum_boundary_residual(&output) <= MAX_BOUNDARY_RESIDUAL);
assert_eq!(output, expected);
}
fn downsampled_rms(input: &[f32]) -> f64 {
let config = AudioResamplerConfig {
output_rate: DOWNSAMPLE_RATE,
..stereo_config()
};
let output_frames = DOWNSAMPLE_RATE as usize / PACKETS_PER_SECOND;
let mut resampler = FixedFrameAudioResampler::new(config, output_frames).unwrap();
let output: Vec<f32> = input
.chunks(INPUT_PACKET_FRAMES * CHANNELS as usize)
.flat_map(|packet| resampler.process(packet).unwrap().into_iter().flatten())
.collect();
assert!(output.len() >= output_frames * CHANNELS as usize * MIN_CONTINUITY_PACKETS);
let mean_square = output
.iter()
.map(|sample| f64::from(*sample).powi(2))
.sum::<f64>()
/ output.len() as f64;
mean_square.sqrt()
}
#[test]
fn capture_downsampling_filters_out_of_band_audio() {
let input: Vec<_> = (0..INPUT_PACKET_FRAMES * PACKET_COUNT)
.flat_map(|frame| {
let phase =
std::f64::consts::TAU * REJECTED_TONE_HZ * frame as f64 / f64::from(INPUT_RATE);
let sample = (f64::from(TONE_AMPLITUDE) * phase.sin()) as f32;
[sample, sample]
})
.collect();
let rms = downsampled_rms(&input);
assert!(
rms < MAX_ALIAS_RMS,
"out-of-band output RMS {rms} exceeded {MAX_ALIAS_RMS}"
);
let input = stereo_tone(INPUT_PACKET_FRAMES * PACKET_COUNT);
let rms = downsampled_rms(&input);
assert!(
rms > MIN_PASSBAND_RMS,
"in-band output RMS {rms} fell below {MIN_PASSBAND_RMS}"
);
}
#[test]
fn samplerate_backend_preserves_streaming_continuity() {
let input = stereo_tone(INPUT_PACKET_FRAMES * PACKET_COUNT);
let mut resampler = AudioResampler::new(stereo_config()).unwrap();
let packets: Vec<_> = input
.chunks(INPUT_PACKET_FRAMES * CHANNELS as usize)
.map(|packet| resampler.process(packet).unwrap())
.filter(|packet| !packet.is_empty())
.collect();
assert!(packets.len() >= MIN_CONTINUITY_PACKETS);
assert!(packets
.iter()
.all(|packet| packet.len() % CHANNELS as usize == 0));
assert!(maximum_boundary_residual(&packets) <= MAX_BOUNDARY_RESIDUAL);
}
#[test]
fn samplerate_backend_reports_incomplete_frame_context() {
let mut resampler = AudioResampler::new(stereo_config()).unwrap();
assert_eq!(
resampler.process(&[0.0]).unwrap_err(),
AudioResamplerError::IncompleteFrame {
samples: INCOMPLETE_SAMPLE_COUNT,
channels: CHANNELS as usize,
}
);
}
-64
View File
@@ -18,8 +18,6 @@ const SECOND_DOWNSAMPLE_PACKET: [f32; 4] = [3.0, 4.0, 5.0, 6.0];
const EXPECTED_DOWNSAMPLED_OUTPUT: [f32; 4] = [0.0, 2.0, 4.0, 6.0];
const PACKETS_PER_SECOND: usize = 100;
const OUTPUT_PACKET_FRAMES: usize = OUTPUT_RATE as usize / PACKETS_PER_SECOND;
const RATE_44_1_KHZ: u32 = 44_100;
const FLOAT_TOLERANCE: f32 = 0.000_001;
const MIN_CONTINUITY_PACKETS: usize = 2;
fn stereo_tone_at_rate(frames: usize, sample_rate: u32) -> Vec<f32> {
@@ -37,11 +35,6 @@ fn stereo_tone(frames: usize) -> Vec<f32> {
stereo_tone_at_rate(frames, INPUT_RATE)
}
fn maximum_tone_prediction_residual(sample_rate: u32) -> f32 {
let half_step = std::f32::consts::PI * TONE_FREQUENCY_HZ / sample_rate as f32;
4.0 * TONE_AMPLITUDE * half_step.sin().powi(2)
}
fn maximum_boundary_residual(chunks: &[Vec<f32>]) -> f32 {
chunks.windows(2).fold(0.0, |maximum, pair| {
let previous = &pair[0];
@@ -89,39 +82,6 @@ fn preserves_decoded_packet_continuity_and_output_ratio() {
}
}
#[test]
fn rejects_incomplete_interleaved_frames() {
let mut resampler = AudioResampler::new(stereo_config()).unwrap();
assert!(resampler.process(&[TONE_AMPLITUDE]).is_err());
}
#[test]
fn interpolates_mono_samples() {
let config = AudioResamplerConfig {
input_rate: UNIT_RATE,
output_rate: DOUBLE_RATE,
channels: MONO_CHANNELS,
};
let mut resampler = AudioResampler::new(config).unwrap();
assert_eq!(
resampler.process(&[0.0, 1.0, 2.0]).unwrap(),
[0.0, 0.5, 1.0, 1.5, 2.0]
);
}
#[test]
fn rejects_zero_rate_configuration() {
let config = AudioResamplerConfig {
input_rate: 0,
output_rate: OUTPUT_RATE,
channels: CHANNELS,
};
assert!(AudioResampler::new(config).is_err());
}
#[test]
fn downsamples_across_packet_boundaries() {
let config = AudioResamplerConfig {
@@ -152,27 +112,3 @@ fn sender_resampler_emits_only_complete_continuous_frames() {
.all(|packet| packet.len() == OUTPUT_PACKET_FRAMES * CHANNELS as usize));
assert!(maximum_boundary_residual(&output) <= MAX_BOUNDARY_RESIDUAL);
}
#[test]
fn sender_downsampling_preserves_packet_continuity() {
let input_packet_frames = RATE_44_1_KHZ as usize / PACKETS_PER_SECOND;
let input = stereo_tone_at_rate(input_packet_frames * CHUNK_COUNT, RATE_44_1_KHZ);
let config = AudioResamplerConfig {
input_rate: RATE_44_1_KHZ,
output_rate: INPUT_RATE,
channels: CHANNELS,
};
let mut resampler =
FixedFrameAudioResampler::new(config, INPUT_RATE as usize / PACKETS_PER_SECOND).unwrap();
let packets: Vec<_> = input
.chunks(input_packet_frames * CHANNELS as usize)
.flat_map(|packet| resampler.process(packet).unwrap())
.collect();
let residual = maximum_boundary_residual(&packets);
assert_eq!(packets.len(), CHUNK_COUNT);
assert!(
residual <= maximum_tone_prediction_residual(INPUT_RATE) + FLOAT_TOLERANCE,
"sender packet boundary residual {residual} exceeded the tone curvature"
);
}
+1 -149
View File
@@ -2328,41 +2328,6 @@ impl AudioBuffer {
}
}
#[cfg(all(test, not(target_os = "linux")))]
mod audio_buffer_discontinuity_tests {
use super::AudioBuffer;
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc, Mutex,
};
const BUFFER_CAPACITY: usize = 4;
const BUFFER_LEVELS: usize = 30;
const FIRST_INPUT: [f32; 2] = [0.1, 0.2];
const OVERFLOWING_INPUT: [f32; 3] = [0.3, 0.4, 0.5];
const OVERSIZED_INPUT: [f32; 5] = [0.6, 0.7, 0.8, 0.9, 1.0];
#[test]
fn capacity_discards_signal_discontinuities() {
let audio_buffer = AudioBuffer(
Arc::new(Mutex::new(ringbuf::HeapRb::new(BUFFER_CAPACITY))),
BUFFER_CAPACITY,
[0; BUFFER_LEVELS],
Arc::new(AtomicUsize::new(0)),
);
assert_eq!(audio_buffer.append_pcm2(&FIRST_INPUT), FIRST_INPUT.len());
assert_eq!(audio_buffer.3.load(Ordering::Relaxed), 0);
assert_eq!(
audio_buffer.append_pcm2(&OVERFLOWING_INPUT),
BUFFER_CAPACITY
);
assert_eq!(audio_buffer.3.load(Ordering::Relaxed), 1);
assert_eq!(audio_buffer.append_pcm2(&OVERSIZED_INPUT), BUFFER_CAPACITY);
assert_eq!(audio_buffer.3.load(Ordering::Relaxed), 2);
}
}
impl AudioHandler {
#[cfg(target_os = "linux")]
fn start_audio(&mut self, format0: AudioFormat) -> ResultType<()> {
@@ -5227,26 +5192,6 @@ mod retry_tests {
}
}
#[cfg(test)]
mod port_forward_mux_tests {
use super::*;
#[test]
fn a_login_asks_for_the_tunnel_when_its_mapping_probes() {
let mut lc = LoginConfigHandler::default();
lc.conn_type = ConnType::PORT_FORWARD;
let asks = |lc: &LoginConfigHandler| {
lc.create_login_msg(String::new(), String::new(), vec![])
.login_request()
.port_forward()
.multiplex
};
assert!(!asks(&lc));
lc.port_forward_multiplex = true;
assert!(asks(&lc));
}
}
pub async fn hc_connection(
feedback: i32,
rendezvous_server: String,
@@ -5439,7 +5384,6 @@ pub mod peer_online {
mod tests {
use crate::client::Client;
use hbb_common::rendezvous_proto::IceCandidate;
use hbb_common::tokio;
#[test]
fn accepts_webrtc_ice_by_session_key_only() {
@@ -5452,22 +5396,6 @@ pub mod peer_online {
assert!(Client::is_expected_webrtc_ice_candidate(&ice, "session-a"));
assert!(!Client::is_expected_webrtc_ice_candidate(&ice, "session-b"));
}
#[tokio::test]
async fn test_query_onlines() {
super::query_online_states(
vec![
"152183996".to_owned(),
"165782066".to_owned(),
"155323351".to_owned(),
"460952777".to_owned(),
],
|onlines: Vec<String>, offlines: Vec<String>| {
println!("onlines: {:?}, offlines: {:?}", &onlines, &offlines);
},
)
.await;
}
}
}
@@ -5593,7 +5521,7 @@ async fn udp_nat_connect(
#[cfg(test)]
mod webrtc_race_tests {
use super::{race_transports_prefer_webrtc, request_allows_tcp_punch};
use super::race_transports_prefer_webrtc;
use hbb_common::{
anyhow::anyhow,
futures::future::{BoxFuture, FutureExt},
@@ -5620,12 +5548,6 @@ mod webrtc_race_tests {
const NOT_P2P: fn(&&'static str) -> bool = |_| false;
#[test]
fn webrtc_request_never_reuses_its_signaling_socket_for_tcp_punch() {
assert!(request_allows_tcp_punch(""));
assert!(!request_allows_tcp_punch("webrtc://offer"));
}
// A direct result must win even when it lands first — the LAN ordering, where ICE beats the
// relay's TCP connect. Parking it as if it were a relay commits the relay on arrival.
#[tokio::test]
@@ -5683,32 +5605,6 @@ mod webrtc_race_tests {
assert_eq!(got, "preferred-relay");
}
#[tokio::test]
async fn relay_from_preferred_branch_committed_when_window_expires() {
let got = race_transports_prefer_webrtc(
ok_after(10, "preferred-relay"),
vec![ok_after(60_000, "too-slow")],
100,
NOT_P2P,
)
.await
.unwrap();
assert_eq!(got, "preferred-relay");
}
#[tokio::test]
async fn preference_window_starts_when_relay_is_ready() {
let got = race_transports_prefer_webrtc(
ok_after(350, "webrtc"),
vec![ok_after(250, "relay")],
200,
NOT_P2P,
)
.await
.unwrap();
assert_eq!(got, "webrtc");
}
#[tokio::test]
async fn unfinished_ipv6_still_beats_held_relay() {
let start = Instant::now();
@@ -5739,36 +5635,6 @@ mod webrtc_race_tests {
assert_eq!(got, "ipv6");
}
// A relay is held with a direct attempt racing behind it, then WebRTC fails. The held relay
// must not be committed while the direct attempt is still in flight.
#[tokio::test]
async fn held_relay_waits_for_racing_direct_when_webrtc_fails() {
let got = race_transports_prefer_webrtc(
err_after(50, "webrtc dead"),
vec![ok_after(10, "relay"), ok_after(100, "ipv6")],
60_000,
|t| *t == "ipv6",
)
.await
.unwrap();
assert_eq!(got, "ipv6");
}
// Both attempts error, but a relay was parked before they did — it is the outcome, not a
// composed error.
#[tokio::test]
async fn held_relay_survives_both_errors() {
let got = race_transports_prefer_webrtc(
err_after(50, "webrtc dead"),
vec![ok_after(10, "relay"), err_after(100, "ipv6 dead")],
60_000,
|t| *t == "ipv6",
)
.await
.unwrap();
assert_eq!(got, "relay");
}
#[tokio::test]
async fn held_relay_committed_when_window_expires() {
let start = Instant::now();
@@ -5839,20 +5705,6 @@ mod webrtc_race_tests {
assert_eq!(got, "ipv6");
assert!(start.elapsed() < Duration::from_secs(5));
}
#[tokio::test]
async fn both_failing_compose_error() {
let err = race_transports_prefer_webrtc(
err_after(10, "webrtc dead"),
vec![err_after(20, "relay dead")],
1_000,
NOT_P2P,
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("webrtc dead") && err.contains("relay dead"), "{}", err);
}
}
#[cfg(test)]
+1 -102
View File
@@ -1,7 +1,4 @@
use super::{
AudioPlaybackConfig, AudioPlaybackError, AudioPlaybackRecovery, AudioPlaybackStatus,
AudioPlaybackWriter,
};
use super::{AudioPlaybackConfig, AudioPlaybackRecovery, AudioPlaybackWriter};
use ringbuf::{ring_buffer::RbBase, Rb};
use std::{
sync::{atomic::Ordering, mpsc, Arc, Mutex},
@@ -93,31 +90,6 @@ fn smooths_underflow_and_explicit_audio_discontinuities() {
}
}
#[test]
fn validates_configuration_and_frame_size() {
let invalid_config = AudioPlaybackConfig {
sample_rate: 0,
channels: CHANNELS,
};
assert_eq!(
AudioPlaybackRecovery::new(invalid_config).err(),
Some(AudioPlaybackError::InvalidConfig(invalid_config))
);
let config = AudioPlaybackConfig {
sample_rate: SAMPLE_RATE,
channels: CHANNELS,
};
let mut recovery = AudioPlaybackRecovery::new(config).unwrap();
assert_eq!(
recovery.process_frame(Some(&[0.5])).err(),
Some(AudioPlaybackError::IncompleteFrame {
samples: 1,
channels: CHANNELS,
})
);
}
const CALLBACK_SAMPLES: usize = 64;
const CALLBACK_TIMEOUT: Duration = Duration::from_secs(2);
const DISCARD_GENERATION: usize = 1;
@@ -187,76 +159,3 @@ fn playback_contention_preserves_queued_audio_and_recovers_after_release() {
assert_eq!(&output[samples - CHANNELS..], &OPPOSITE_ACTIVE_FRAME);
assert_eq!(buffer.lock().unwrap().occupied_len(), 0);
}
#[test]
fn poisoned_playback_buffer_reports_once_without_panicking_in_the_callback() {
let buffer = Arc::new(Mutex::new(ringbuf::HeapRb::new(CALLBACK_SAMPLES)));
let config = AudioPlaybackConfig {
sample_rate: SAMPLE_RATE,
channels: CHANNELS,
};
let mut writer =
AudioPlaybackWriter::new(config, buffer.clone(), Arc::new(super::AtomicUsize::new(0)))
.unwrap();
assert!(std::thread::spawn(move || {
let _guard = buffer.lock().unwrap();
panic!("Injected PCM buffer failure");
})
.join()
.is_err());
let mut output = [ACTIVE_FRAME[0]; CALLBACK_SAMPLES];
crate::audio_resampler::allocation_tests::assert_no_allocations(|| {
writer.write_output(&mut output);
});
assert_eq!(output, [0.0; CALLBACK_SAMPLES]);
assert!(!writer.status.ready.load(Ordering::Acquire));
assert!(writer.status.buffer_poisoned.load(Ordering::Relaxed));
writer.status.report_errors();
writer.write_output(&mut output);
assert!(!writer.status.buffer_poisoned.load(Ordering::Relaxed));
assert_eq!(writer.status.contentions.load(Ordering::Relaxed), 0);
assert!(!writer.status.ready.load(Ordering::Acquire));
}
#[test]
fn contention_counts_accumulate_until_the_next_report() {
let status = AudioPlaybackStatus::default();
status.report_errors();
status.contentions.fetch_add(1, Ordering::Relaxed);
status.report_errors();
assert_eq!(status.contentions.load(Ordering::Relaxed), 0);
let mut total = 0;
for callbacks in [3, 7, 2] {
total += callbacks;
status.contentions.fetch_add(callbacks, Ordering::Relaxed);
status.report_errors();
assert_eq!(status.contentions.load(Ordering::Relaxed), total);
}
status.buffer_poisoned.store(true, Ordering::Relaxed);
status.report_errors();
assert!(!status.buffer_poisoned.load(Ordering::Relaxed));
assert_eq!(status.contentions.load(Ordering::Relaxed), total);
std::thread::sleep(super::AUDIO_PLAYBACK_LOG_INTERVAL);
status.report_errors();
assert_eq!(status.contentions.load(Ordering::Relaxed), 0);
}
#[test]
fn contention_reporting_is_independent_between_playbacks() {
let first = AudioPlaybackStatus::default();
let second = AudioPlaybackStatus::default();
for status in [&first, &second] {
status.contentions.fetch_add(1, Ordering::Relaxed);
status.report_errors();
assert_eq!(status.contentions.load(Ordering::Relaxed), 0);
}
for status in [&first, &second] {
status.contentions.fetch_add(1, Ordering::Relaxed);
status.report_errors();
assert_eq!(status.contentions.load(Ordering::Relaxed), 1);
}
}
+1 -84
View File
@@ -2767,7 +2767,7 @@ impl<T: InvokeUiSession> Remote<T> {
/// A shape that decoded, still compressed, under the id the UI knows it by. Copied once it
/// decoded: the message's colors may be a slice of a larger received buffer, which a clone
/// would keep alive.
#[cfg(any(feature = "flutter", test))]
#[cfg(feature = "flutter")]
fn cursor_shape(id: u64, cd: &CursorData, compressed: &[u8]) -> CursorData {
CursorData {
id,
@@ -3022,74 +3022,6 @@ mod cursor_dedupe_tests {
assert_eq!(dedupe.id(2), first);
assert_eq!(dedupe.id(1), first);
}
#[test]
fn the_hotspot_is_part_of_the_shape() {
let a = CursorDedupe::name(&shape(1, 0, b"arrow"));
let b = CursorDedupe::name(&shape(2, 1, b"arrow"));
assert_ne!(a, b);
}
#[test]
fn a_reused_handle_names_its_latest_shape() {
let mut dedupe = CursorDedupe::default();
dedupe.record(1, CursorDedupe::name(&shape(1, 0, b"arrow")));
let beam = CursorDedupe::name(&shape(1, 0, b"beam"));
dedupe.record(1, beam);
assert_eq!(dedupe.id(1), beam);
}
#[test]
fn a_rejected_shape_names_its_handle_and_is_never_decoded() {
let mut dedupe = CursorDedupe::default();
let arrow = CursorDedupe::name(&shape(7, 0, b"arrow"));
dedupe.record(7, arrow);
let beam = CursorDedupe::name(&shape(8, 0, b"beam"));
dedupe.record(8, beam);
let broken = CursorDedupe::name(&shape(7, 0, b"broken"));
assert_eq!(
dedupe.rejected(7, broken),
broken,
"not the shape it named before"
);
assert_eq!(
dedupe.id(7),
broken,
"nor when the handle is selected again"
);
assert_eq!(dedupe.id(8), beam, "another handle keeps its shape");
assert!(!dedupe.has_decoded(broken));
assert!(
dedupe.has_decoded(arrow),
"kept for a handle that brings it again"
);
dedupe.record(7, arrow);
assert_eq!(dedupe.id(7), arrow);
}
#[test]
fn a_shape_rejected_under_handles_never_seen_files_nothing() {
let mut dedupe = CursorDedupe::default();
let broken = CursorDedupe::name(&shape(0, 0, b"broken"));
for handle in 0..1000 {
assert_eq!(dedupe.rejected(handle, broken), broken);
}
assert!(dedupe.ids.is_empty());
assert!(!dedupe.has_decoded(broken));
}
#[test]
fn an_id_the_peer_never_sent_is_passed_on() {
assert_eq!(CursorDedupe::default().id(7), 7);
}
#[test]
fn peers_from_1_5_0_name_cursors_by_content() {
use hbb_common::get_version_number as v;
assert!(!crate::is_peer_naming_cursors_by_content(v("1.4.9")));
assert!(crate::is_peer_naming_cursors_by_content(v("1.5.0")));
assert!(crate::is_peer_naming_cursors_by_content(v("1.5.1")));
}
}
#[cfg(test)]
@@ -3107,21 +3039,6 @@ mod kept_cursor_tests {
}
}
#[test]
fn a_kept_shape_is_a_copy_under_the_ui_id() {
let mut cd = compressed(4, 4);
cd.hotx = 1;
let kept = cursor_shape(42, &cd, &cd.colors);
assert_eq!(kept.id, 42);
assert_eq!((kept.hotx, kept.width, kept.height), (1, 4, 4));
assert_eq!(kept.colors, cd.colors);
assert_ne!(
kept.colors.as_ptr(),
cd.colors.as_ptr(),
"not a view of the received buffer"
);
}
#[test]
fn a_kept_shape_is_checked_again_when_it_is_read() {
assert_eq!(kept_cursor_rgba(compressed(4, 4)).unwrap().colors.len(), 64);
@@ -5,9 +5,6 @@ use crate::client::{
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>,
@@ -82,27 +79,6 @@ fn unconfirmed_start_retries_without_callback_or_error() {
});
}
#[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();
@@ -157,23 +133,6 @@ fn rollback_keeps_the_restarted_decoders_accumulated_history() {
);
}
#[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();
@@ -194,33 +153,3 @@ fn both_outputs_failing_retains_format_and_paces_recovery() {
});
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());
}
-370
View File
@@ -3173,55 +3173,6 @@ mod tests {
}
}
// ThrottledInterval tick at the same time as tokio interval, if no sleeps
#[allow(non_snake_case)]
#[tokio::test]
async fn test_RustDesk_interval() {
let base_intervals = [interval_maker, interval_at_maker];
for maker in base_intervals.into_iter() {
let mut tokio_timer = maker();
let mut tokio_times = Vec::new();
let mut timer = rustdesk_interval(maker());
let mut times = Vec::new();
loop {
tokio::select! {
_ = timer.tick() => {
if tokio_times.len() >= 10 && times.len() >= 10 {
break;
}
times.push(get_timestamp_secs());
}
_ = tokio_timer.tick() => {
if tokio_times.len() >= 10 && times.len() >= 10 {
break;
}
tokio_times.push(get_timestamp_secs());
}
}
}
assert_eq!(times, tokio_times);
}
}
#[tokio::test]
async fn test_tokio_time_interval_sleep() {
let mut timer = interval_maker();
let mut times = Vec::new();
sleep(Duration::from_secs(3)).await;
loop {
tokio::select! {
_ = timer.tick() => {
times.push(get_timestamp_secs());
if times.len() == 5 {
break;
}
}
}
}
let times2: HashSet<u128> = HashSet::from_iter(times.clone());
assert_eq!(times.len(), times2.len() + 3);
}
// ThrottledInterval tick less times than tokio interval, if there're sleeps
#[allow(non_snake_case)]
#[tokio::test]
@@ -3243,72 +3194,11 @@ mod tests {
}
// No multiple ticks in the `interval` time.
// Values in "times" are unique and are less than normal tokio interval.
// See previous test (test_tokio_time_interval_sleep) for comparison.
let times2: HashSet<u128> = HashSet::from_iter(times.clone());
assert_eq!(times.len(), times2.len(), "test: {}", i);
}
}
#[test]
fn test_duration_multiplication() {
let dur = Duration::from_secs(1);
assert_eq!(dur * 2, Duration::from_secs(2));
assert_eq!(
Duration::from_secs_f64(dur.as_secs_f64() * 0.9),
Duration::from_millis(900)
);
assert_eq!(
Duration::from_secs_f64(dur.as_secs_f64() * 0.923),
Duration::from_millis(923)
);
assert_eq!(
Duration::from_secs_f64(dur.as_secs_f64() * 0.923 * 1e-3),
Duration::from_micros(923)
);
assert_eq!(
Duration::from_secs_f64(dur.as_secs_f64() * 0.923 * 1e-6),
Duration::from_nanos(923)
);
assert_eq!(
Duration::from_secs_f64(dur.as_secs_f64() * 0.923 * 1e-9),
Duration::from_nanos(1)
);
assert_eq!(
Duration::from_secs_f64(dur.as_secs_f64() * 0.5 * 1e-9),
Duration::from_nanos(1)
);
assert_eq!(
Duration::from_secs_f64(dur.as_secs_f64() * 0.499 * 1e-9),
Duration::from_nanos(0)
);
}
#[test]
fn test_is_public() {
// Test URLs containing "rustdesk.com/"
assert!(is_public("https://rustdesk.com/"));
assert!(is_public("https://www.rustdesk.com/"));
assert!(is_public("https://api.rustdesk.com/v1"));
assert!(is_public("https://API.RUSTDESK.COM/v1"));
assert!(is_public("https://rustdesk.com/path"));
// Test URLs ending with "rustdesk.com"
assert!(is_public("rustdesk.com"));
assert!(is_public("https://rustdesk.com"));
assert!(is_public("https://RustDesk.com"));
assert!(is_public("http://www.rustdesk.com"));
assert!(is_public("https://api.rustdesk.com"));
// Test non-public URLs
assert!(!is_public("https://example.com"));
assert!(!is_public("https://custom-server.com"));
assert!(!is_public("http://192.168.1.1"));
assert!(!is_public("localhost"));
assert!(!is_public("https://rustdesk.computer.com"));
assert!(!is_public("rustdesk.comhello.com"));
}
#[test]
fn test_is_public_matches_rustdesk_root_domain() {
assert!(is_public("rustdesk.com/"));
@@ -3347,112 +3237,6 @@ mod tests {
));
}
#[test]
fn test_should_throttle_log() {
assert!(should_throttle_log("https://example.com/api/heartbeat"));
assert!(should_throttle_log(
"https://example.com/api/heartbeat?token=secret"
));
assert!(should_throttle_log("https://example.com/prefix/api/heartbeat"));
assert!(!should_throttle_log("https://example.com/api/heartbeat2"));
assert!(!should_throttle_log("https://example.com/api/sysinfo"));
assert!(!should_throttle_log(
"https://example.com/api/sysinfo?next=/api/heartbeat"
));
assert!(!should_throttle_log("not a url"));
}
#[test]
fn test_get_tcp_proxy_addr_normalizes_bare_ipv6_host() {
struct RestoreCustomRendezvousServer(String);
impl Drop for RestoreCustomRendezvousServer {
fn drop(&mut self) {
Config::set_option(
keys::OPTION_CUSTOM_RENDEZVOUS_SERVER.to_string(),
self.0.clone(),
);
}
}
let _restore = RestoreCustomRendezvousServer(Config::get_option(
keys::OPTION_CUSTOM_RENDEZVOUS_SERVER,
));
Config::set_option(
keys::OPTION_CUSTOM_RENDEZVOUS_SERVER.to_string(),
"1:2".to_string(),
);
assert_eq!(get_tcp_proxy_addr(), format!("[1:2]:{RENDEZVOUS_PORT}"));
}
#[tokio::test]
async fn test_http_request_via_tcp_proxy_rejects_invalid_header_json() {
let result = http_request_via_tcp_proxy("not a url", "get", None, "{").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_http_request_via_tcp_proxy_rejects_non_object_header_json() {
let err = http_request_via_tcp_proxy("not a url", "get", None, "[]")
.await
.unwrap_err()
.to_string();
assert!(err.contains("HTTP header information parsing failed!"));
}
#[test]
fn test_parse_json_header_entries_preserves_single_content_type() {
let headers = parse_json_header_entries(
r#"{"Content-Type":"text/plain","Authorization":"Bearer token"}"#,
)
.unwrap();
assert_eq!(
headers
.iter()
.filter(|entry| entry.name.eq_ignore_ascii_case("Content-Type"))
.count(),
1
);
assert_eq!(
headers
.iter()
.find(|entry| entry.name.eq_ignore_ascii_case("Content-Type"))
.map(|entry| entry.value.as_str()),
Some("text/plain")
);
}
#[test]
fn test_parse_json_header_entries_does_not_add_default_content_type() {
let headers = parse_json_header_entries(r#"{"Authorization":"Bearer token"}"#).unwrap();
assert!(!headers
.iter()
.any(|entry| entry.name.eq_ignore_ascii_case("Content-Type")));
}
#[test]
fn test_parse_simple_header_respects_custom_content_type() {
let headers = parse_simple_header("Content-Type: text/plain");
assert_eq!(
headers
.iter()
.filter(|entry| entry.name.eq_ignore_ascii_case("Content-Type"))
.count(),
1
);
assert_eq!(
headers
.iter()
.find(|entry| entry.name.eq_ignore_ascii_case("Content-Type"))
.map(|entry| entry.value.as_str()),
Some("text/plain")
);
}
#[test]
fn test_parse_simple_header_preserves_non_content_type_header() {
let headers = parse_simple_header("Authorization: Bearer token");
@@ -3477,22 +3261,6 @@ mod tests {
);
}
#[test]
fn test_tcp_proxy_log_target_redacts_query_only() {
assert_eq!(
tcp_proxy_log_target("https://example.com/api/heartbeat?token=secret"),
"https://example.com/api/heartbeat"
);
}
#[test]
fn test_tcp_proxy_log_target_brackets_ipv6_host_with_port() {
assert_eq!(
tcp_proxy_log_target("https://[2001:db8::1]:21114/api/heartbeat?token=secret"),
"https://[2001:db8::1]:21114/api/heartbeat"
);
}
#[test]
fn test_http_proxy_response_to_json() {
let mut resp = HttpProxyResponse {
@@ -3521,38 +3289,6 @@ mod tests {
assert!(err.contains("TCP proxy error: dial failed"));
}
#[test]
fn test_mouse_event_constants_and_mask_layout() {
use super::input::*;
// Verify MOUSE_TYPE constants are unique and within the mask range.
let types = [
MOUSE_TYPE_MOVE,
MOUSE_TYPE_DOWN,
MOUSE_TYPE_UP,
MOUSE_TYPE_WHEEL,
MOUSE_TYPE_TRACKPAD,
MOUSE_TYPE_MOVE_RELATIVE,
];
let mut seen = std::collections::HashSet::new();
for t in types.iter() {
assert!(seen.insert(*t), "Duplicate mouse type: {}", t);
assert_eq!(
*t & MOUSE_TYPE_MASK,
*t,
"Mouse type {} exceeds mask {}",
t,
MOUSE_TYPE_MASK
);
}
// The mask layout is: lower 3 bits for type, upper bits for buttons (shifted by 3).
let combined_mask = MOUSE_TYPE_DOWN | ((MOUSE_BUTTON_LEFT | MOUSE_BUTTON_RIGHT) << 3);
assert_eq!(combined_mask & MOUSE_TYPE_MASK, MOUSE_TYPE_DOWN);
assert_eq!(combined_mask >> 3, MOUSE_BUTTON_LEFT | MOUSE_BUTTON_RIGHT);
}
/// A stand-in rendezvous server on loopback: accepts one connection and hands it to `serve`.
async fn rendezvous_stub<F, Fut>(serve: F) -> String
where
@@ -3623,105 +3359,6 @@ mod tests {
assert!(!conn.is_secured());
}
#[tokio::test]
async fn test_secure_tcp_required_refuses_a_closed_connection() {
let (key, _) = server_key();
let host = rendezvous_stub(|s| async move { drop(s) }).await;
let mut conn = connect(&host).await;
assert!(secure_tcp_required(&mut conn, &key).await.is_err());
assert!(!conn.is_secured());
}
#[tokio::test]
async fn test_secure_tcp_required_accepts_a_completed_exchange() {
let (key, sk) = server_key();
let host = rendezvous_stub(move |mut s| async move {
let (eph_pk, eph_sk) = box_::gen_keypair();
let mut msg = RendezvousMessage::new();
msg.set_key_exchange(KeyExchange {
keys: vec![sign::sign(&eph_pk.0, &sk).into()],
..Default::default()
});
s.send(&msg).await.unwrap();
// The client's reply must decode to a key with the ephemeral secret half.
let reply = s.next_timeout(3000).await.unwrap().unwrap();
let reply = RendezvousMessage::parse_from_bytes(&reply).unwrap();
let Some(rendezvous_message::Union::KeyExchange(ex)) = reply.union else {
panic!("expected the client's key exchange");
};
hbb_common::tcp::Encrypt::decode(&ex.keys[1], &ex.keys[0], &eph_sk).unwrap();
})
.await;
let mut conn = connect(&host).await;
secure_tcp_required(&mut conn, &key).await.unwrap();
assert!(conn.is_secured());
}
// The stand-in does what hbbs does at version 1: advertises it and splits the exchanged key
// over the same transcript. Neither side's unit tests can catch a client that puts a
// different byte string into the transcript than the server does; only a frame crossing
// between the two can.
#[tokio::test]
async fn test_secure_tcp_version_1_keys_match_the_server_both_ways() {
let (key, sk) = server_key();
let host = rendezvous_stub(move |mut s| async move {
let (eph_pk, eph_sk) = box_::gen_keypair();
let mut msg = RendezvousMessage::new();
msg.set_key_exchange(KeyExchange {
keys: vec![sign::sign(&eph_pk.0, &sk).into()],
version: 1,
..Default::default()
});
s.send(&msg).await.unwrap();
let reply = s.next_timeout(3000).await.unwrap().unwrap();
let reply = RendezvousMessage::parse_from_bytes(&reply).unwrap();
let Some(rendezvous_message::Union::KeyExchange(ex)) = reply.union else {
panic!("expected the client's key exchange");
};
let shared =
hbb_common::tcp::Encrypt::decode(&ex.keys[1], &ex.keys[0], &eph_sk).unwrap();
s.set_key_split(
shared,
false,
&hbb_common::tcp::KxTranscript {
initiator_pk: &ex.keys[0],
responder_pk: &eph_pk.0,
advertised: 1,
picked: ex.version,
},
)
.unwrap();
// Answers with the request's serial, so the client learns from its own reply that
// the request was read under the right key.
let request = s.next_timeout(3000).await.unwrap().unwrap();
let request = RendezvousMessage::parse_from_bytes(&request).unwrap();
let Some(rendezvous_message::Union::TestNatRequest(nat)) = request.union else {
panic!("expected the client's nat request");
};
let mut msg = RendezvousMessage::new();
msg.set_test_nat_response(TestNatResponse {
port: nat.serial,
..Default::default()
});
s.send(&msg).await.unwrap();
})
.await;
let mut conn = connect(&host).await;
secure_tcp_required(&mut conn, &key).await.unwrap();
let mut msg = RendezvousMessage::new();
msg.set_test_nat_request(TestNatRequest {
serial: 7,
..Default::default()
});
conn.send(&msg).await.unwrap();
let reply = conn.next_timeout(3000).await.unwrap().unwrap();
let reply = RendezvousMessage::parse_from_bytes(&reply).unwrap();
let Some(rendezvous_message::Union::TestNatResponse(nat)) = reply.union else {
panic!("expected the server's nat response");
};
assert_eq!(nat.port, 7);
}
#[tokio::test]
async fn test_secure_tcp_legacy_and_signed_servers_exchange_application_data() {
for (advertised, signed) in [(0, false), (1, true), (3, true)] {
@@ -3936,11 +3573,4 @@ mod tests {
// non-WebRTC handshakes.
assert_eq!(decode_id_pk(&signed, &pk).unwrap(), (id, their_pk));
}
// The route probe is awaited on the connection path, so whatever it finds - an address, or
// no IPv6 route on this machine - it finds without waiting on the network.
#[tokio::test]
async fn test_ipv6_route_probe_does_not_wait_on_the_network() {
assert!(hbb_common::timeout(1_000, test_bind_ipv6()).await.is_ok());
}
}
-53
View File
@@ -900,59 +900,6 @@ fn parse_silent_install_args(args: &[String]) -> (Option<bool>, bool) {
(printer_override, debug)
}
#[cfg(test)]
mod tests {
use super::*;
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(|value| value.to_string()).collect()
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn user_main_ipc_scope_cli_command_matches_management_commands_only() {
for command in [
"--password",
"--set-unlock-pin",
"--get-id",
"--set-id",
"--config",
"--option",
"--assign",
"--deploy",
] {
assert!(is_user_main_ipc_scope_cli_command(&args(&[command])));
}
for command in [
"--service",
"--server",
"--tray",
"--cm",
"--check-hwcodec-config",
"--connect",
] {
assert!(!is_user_main_ipc_scope_cli_command(&args(&[command])));
}
}
#[cfg(target_os = "macos")]
#[test]
fn macos_manual_update_result_preserves_failure() {
let failure =
finish_macos_update(Err(hbb_common::anyhow::anyhow!("administrator canceled")))
.expect("failure action");
match failure {
CoreMainAction::ExitFailure(err) => {
assert_eq!("administrator canceled", err.to_string());
}
CoreMainAction::StartUi(_) => panic!("update failure started the UI"),
}
assert!(finish_macos_update(Ok(())).is_none());
}
}
/// Check if the executable is a Quick Support version.
/// Note: This function must be kept in sync with `libs/portable/src/main.rs`.
#[cfg(windows)]
-12
View File
@@ -3104,15 +3104,3 @@ pub mod server_side {
jboolean::from(crate::server::is_clipboard_service_ok())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sync_common_rejects_verified_download_url() {
let result = main_get_common_sync("verified-download-url-".to_owned());
assert_eq!(result.0, "error:unsupported");
}
}
+1 -139
View File
@@ -2268,43 +2268,9 @@ pub async fn set_install_option(k: String, v: String) -> ResultType<()> {
#[cfg(test)]
mod test {
#[cfg(any(target_os = "linux", target_os = "macos"))]
use super::*;
#[test]
fn verify_ffi_enum_data_size() {
println!("{}", std::mem::size_of::<Data>());
assert!(std::mem::size_of::<Data>() <= 120);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn test_service_ipc_path_is_shared_across_uids() {
assert_eq!(
Config::ipc_path_for_uid(0, crate::POSTFIX_SERVICE),
Config::ipc_path_for_uid(501, crate::POSTFIX_SERVICE)
);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn test_ipc_path_differs_by_uid_for_cm() {
let effective_uid = unsafe { hbb_common::libc::geteuid() as u32 };
let other_uid = effective_uid.saturating_add(1);
let postfix = "_cm";
// Default connect path targets the current effective uid.
assert_eq!(
Config::ipc_path(postfix),
Config::ipc_path_for_uid(effective_uid, postfix)
);
// A different uid yields a different socket path - this is the root cause of the
// cross-user regression when root spawns a user process but still connects as uid 0.
assert_ne!(
Config::ipc_path(postfix),
Config::ipc_path_for_uid(other_uid, postfix)
);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn test_select_server_uid_uses_active_uid_when_no_server_found() {
@@ -2346,108 +2312,4 @@ mod test {
fn test_select_server_uid_fails_when_multiple_servers_are_ambiguous() {
assert!(select_server_uid_for_user_main_ipc(&[501, 502], None, false).is_err());
}
/// The upgrade window: an old root service is still streaming when a new `--server` starts,
/// and its DrmCursor carries no `hot_measured` at all. Read as `false` the consumer would
/// discard a hotspot the producer did measure and re-infer one from the bitmap, which moves
/// the cursor on a rotated display until the old process is replaced. `bool::default()` is
/// the wrong default here; the right one preserves what the old protocol meant.
#[cfg(all(target_os = "linux", feature = "drm"))]
#[test]
fn a_drm_cursor_without_provenance_keeps_the_hotspot_the_producer_sent() {
// `Data` is adjacently tagged (`tag = "t", content = "c"`), so this is the real shape on
// the socket, not a simplified one.
let legacy =
r#"{"t":"DrmCursor","c":{"id":7,"width":24,"height":24,"hotx":12,"hoty":11}}"#;
let msg: Data = serde_json::from_str(legacy).expect("legacy DrmCursor must deserialize");
match msg {
Data::DrmCursor {
hotx,
hoty,
hot_measured,
..
} => {
assert_eq!((hotx, hoty), (12, 11));
assert!(
hot_measured,
"a message with no provenance field must be treated as the old protocol did, \
i.e. use the hotspot as sent, not re-infer it"
);
}
other => panic!("expected DrmCursor, got {other:?}"),
}
// And a NEW producer that really wants re-inference says so, which serializes: the
// default must not swallow an explicit false.
let modern = r#"{"t":"DrmCursor","c":{"id":7,"width":24,"height":24,"hotx":0,"hoty":0,"hot_measured":false}}"#;
match serde_json::from_str::<Data>(modern).expect("modern DrmCursor must deserialize") {
Data::DrmCursor { hot_measured, .. } => assert!(!hot_measured),
other => panic!("expected DrmCursor, got {other:?}"),
}
}
/// A frame from a producer that predates the plane rotation reads as `None`, in the cpu frame
/// header and in the dma-buf descriptor: the root service and the `--server` are upgraded
/// separately, and `None` keeps the previous rule.
#[cfg(all(target_os = "linux", feature = "drm"))]
#[test]
fn a_frame_without_a_plane_rotation_reads_as_none() {
let legacy = r#"{"t":"DrmFrame","c":{"width":8,"height":8}}"#;
match serde_json::from_str::<Data>(legacy).expect("legacy DrmFrame must deserialize") {
Data::DrmFrame { plane_rotation, .. } => assert_eq!(plane_rotation, None),
other => panic!("expected DrmFrame, got {other:?}"),
}
let modern = r#"{"t":"DrmFrame","c":{"width":8,"height":8,"plane_rotation":4}}"#;
match serde_json::from_str::<Data>(modern).expect("modern DrmFrame must deserialize") {
Data::DrmFrame { plane_rotation, .. } => assert_eq!(plane_rotation, Some(4)),
other => panic!("expected DrmFrame, got {other:?}"),
}
let desc = DmabufDesc {
buffer_id: 0,
width: 4,
height: 4,
format: 0,
modifier: 0,
fb_id: 0,
num_planes: 1,
offsets: [0; 4],
pitches: [16, 0, 0, 0],
hdr_eotf: 0,
hdr_max_nits: 0,
has_fd: false,
plane_rotation: Some(4),
cursor_pos: None,
};
let mut value = serde_json::to_value(&desc).unwrap();
assert!(value.as_object_mut().unwrap().remove("plane_rotation").is_some());
let legacy: DmabufDesc =
serde_json::from_value(value).expect("legacy DmabufDesc must deserialize");
assert_eq!(legacy.plane_rotation, None);
}
/// A frame header from a producer that predates the cursor plane position reads as `None`;
/// a producer that sends one is read back exactly. A pin, not a gate: the consumer only ever
/// measures against `Some`.
#[cfg(all(target_os = "linux", feature = "drm"))]
#[test]
fn a_drm_frame_without_a_cursor_position_reads_as_none() {
let legacy = r#"{"t":"DrmFrame","c":{"width":8,"height":8}}"#;
match serde_json::from_str::<Data>(legacy).expect("legacy DrmFrame must deserialize") {
Data::DrmFrame {
width,
height,
cursor_pos,
..
} => {
assert_eq!((width, height), (8, 8));
assert_eq!(cursor_pos, None);
}
other => panic!("expected DrmFrame, got {other:?}"),
}
let modern = r#"{"t":"DrmFrame","c":{"width":8,"height":8,"cursor_pos":[3,4]}}"#;
match serde_json::from_str::<Data>(modern).expect("modern DrmFrame must deserialize") {
Data::DrmFrame { cursor_pos, .. } => assert_eq!(cursor_pos, Some((3, 4))),
other => panic!("expected DrmFrame, got {other:?}"),
}
}
}
-71
View File
@@ -928,37 +928,6 @@ mod tests {
));
}
#[test]
#[cfg(windows)]
fn test_should_allow_everyone_create_on_windows_policy() {
assert!(super::should_allow_everyone_create_on_windows(""));
assert!(super::should_allow_everyone_create_on_windows("_service"));
assert!(!super::should_allow_everyone_create_on_windows(
"_portable_service"
));
}
#[test]
#[cfg(windows)]
fn test_executable_paths_match_windows_normalization() {
let left = std::path::PathBuf::from(r"\\?\C:\Program Files\RustDesk\RustDesk.exe");
let right = std::path::PathBuf::from(r"c:\program files\rustdesk\rustdesk.exe");
assert!(super::executable_paths_match(&left, &right));
}
#[test]
#[cfg(target_os = "macos")]
fn test_os_str_eq_ignore_ascii_case_for_process_names() {
assert!(super::os_str_eq_ignore_ascii_case(
Some(std::ffi::OsStr::new("RustDesk")),
Some(std::ffi::OsStr::new("rustdesk"))
));
assert!(!super::os_str_eq_ignore_ascii_case(
Some(std::ffi::OsStr::new("RustDesk")),
Some(std::ffi::OsStr::new("service"))
));
}
#[cfg(all(windows, not(feature = "flutter")))]
struct TempDirGuard(std::path::PathBuf);
@@ -994,44 +963,4 @@ mod tests {
"helper trust check must reject path-match-only binaries with mismatched content"
);
}
#[test]
#[cfg(all(windows, not(feature = "flutter")))]
fn test_portable_service_helper_trust_accepts_matching_content() {
let unique = format!(
"rustdesk-portable-helper-trust-match-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let base = std::env::temp_dir().join(unique);
std::fs::create_dir_all(&base).unwrap();
let _cleanup = TempDirGuard(base.clone());
let current_exe = base.join("current.exe");
let helper_exe = base.join("helper.exe");
std::fs::write(&current_exe, b"trusted-binary").unwrap();
std::fs::write(&helper_exe, b"trusted-binary").unwrap();
assert!(super::portable_service_helper_is_trusted(
&helper_exe,
&helper_exe,
&current_exe
));
}
#[cfg(target_os = "macos")]
#[test]
fn test_console_owner_uid_matches_get_active_userid() {
let console_uid =
super::console_owner_uid().expect("/dev/console must have a resolvable uid");
let raw_uid = crate::platform::macos::get_active_userid();
let parsed_uid: u32 = raw_uid
.trim()
.parse()
.unwrap_or_else(|_| panic!("failed to parse get_active_userid() output: '{raw_uid}'"));
assert_eq!(parsed_uid, console_uid);
}
}
-240
View File
@@ -219,22 +219,6 @@ mod cursor_pos_tests {
stamp_cursor_pos(&mut shape, Some(&visible));
assert!(matches!(&shape, DrmProducerMsg::Cursor { hotx: 1, hoty: 1, .. }));
}
// A producer that predates the field: the descriptor arrives without it and reads as None.
#[test]
fn a_descriptor_without_a_cursor_position_reads_as_none() {
let mut v = serde_json::to_value(desc()).unwrap();
v.as_object_mut().unwrap().remove("cursor_pos").expect("the field is serialized");
let d: DmabufDesc = serde_json::from_value(v).expect("a legacy descriptor must deserialize");
assert_eq!(d.cursor_pos, None);
let d: DmabufDesc = serde_json::from_value(serde_json::to_value(DmabufDesc {
cursor_pos: Some((3, 4)),
..desc()
})
.unwrap())
.unwrap();
assert_eq!(d.cursor_pos, Some((3, 4)));
}
}
struct DrmStopGuard(std::sync::Arc<std::sync::atomic::AtomicBool>);
@@ -1887,91 +1871,12 @@ mod drm_conn_tests {
use hbb_common::tokio::{self, io::AsyncWriteExt};
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
// Added to the wire later: an older peer's message must still decode.
#[test]
fn drm_display_info_decodes_without_render_node() {
let legacy = r#"{"name":"DP-1","crtc_id":386,"x":0,"y":0,
"width":3840,"height":2160,"active":true}"#;
let info: DrmDisplayInfo =
serde_json::from_str(legacy).expect("a pre-render_node payload must still decode");
assert_eq!(info.name, "DP-1");
assert_eq!(info.crtc_id, 386);
assert!(info.render_node.is_empty(), "missing node; the consumer auto-selects only where there is one render node");
assert!(info.device.is_empty(), "missing device means auto-detect");
let current = DrmDisplayInfo {
name: "DP-1".to_owned(),
crtc_id: 386,
x: 0,
y: 0,
width: 3840,
height: 2160,
active: true,
render_node: "/dev/dri/renderD129".to_owned(),
device: "/dev/dri/card2".to_owned(),
plane_transposed: false,
};
let wire = serde_json::to_vec(&current).unwrap();
let back: DrmDisplayInfo = serde_json::from_slice(&wire).unwrap();
assert_eq!(back, current);
}
fn pipe() -> (OwnedFd, OwnedFd) {
let mut fds = [0 as libc::c_int; 2];
assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe() failed");
unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) }
}
unsafe fn send_with_fds(sock: libc::c_int, data: &[u8], fds: &[libc::c_int]) -> isize {
let mut iov = libc::iovec {
iov_base: data.as_ptr() as *mut libc::c_void,
iov_len: data.len(),
};
let fdbytes = fds.len() * std::mem::size_of::<libc::c_int>();
let space = libc::CMSG_SPACE(fdbytes as u32) as usize;
let mut cbuf = vec![0u8; space];
let mut msg: libc::msghdr = std::mem::zeroed();
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = cbuf.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = space as _;
let cmsg = libc::CMSG_FIRSTHDR(&msg);
(*cmsg).cmsg_level = libc::SOL_SOCKET;
(*cmsg).cmsg_type = libc::SCM_RIGHTS;
(*cmsg).cmsg_len = libc::CMSG_LEN(fdbytes as u32) as _;
std::ptr::copy_nonoverlapping(fds.as_ptr() as *const u8, libc::CMSG_DATA(cmsg), fdbytes);
libc::sendmsg(sock, &msg, 0)
}
#[tokio::test]
async fn roundtrip_msg_no_fd() {
let (a, b) = tokio::net::UnixStream::pair().unwrap();
let mut tx = DrmConn::new(a);
let mut rx = DrmConn::new(b);
tx.send_msg(
&Data::DrmFrame {
width: 1920,
height: 1080,
plane_rotation: None,
cursor_pos: None,
},
None,
)
.await
.unwrap();
let (data, fd) = rx.recv_msg().await.unwrap();
assert!(matches!(
data,
Data::DrmFrame {
width: 1920,
height: 1080,
plane_rotation: None,
cursor_pos: None
}
));
assert!(fd.is_none(), "no fd was sent, none must be reported");
}
#[tokio::test]
async fn roundtrip_msg_with_fd_identity() {
let (a, b) = tokio::net::UnixStream::pair().unwrap();
@@ -2037,131 +1942,6 @@ mod drm_conn_tests {
);
}
#[tokio::test]
async fn a_body_that_never_arrives_times_out() {
let (mut a, b) = tokio::net::UnixStream::pair().unwrap();
let mut rx = DrmConn::new(b);
a.write_all(&10u32.to_be_bytes()).await.unwrap();
let mut got = Vec::new();
let err = rx
.next_raw_into(&mut got)
.await
.err()
.expect("a body that never arrives must time out");
assert!(
err.to_string().contains("did not arrive"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn a_dripping_peer_cannot_re_arm_the_send_deadline() {
use tokio::io::AsyncReadExt;
let (mut reader, writer) = tokio::net::UnixStream::pair().unwrap();
let payload = vec![0u8; 32 * 1024 * 1024];
// Measured: 1 KiB drains do not re-assert POLLOUT; 64 KiB does, which separates the forms.
let drip = tokio::spawn(async move {
let mut sink = vec![0u8; 64 * 1024];
loop {
if reader.read(&mut sink).await.unwrap_or(0) == 0 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
});
let started = std::time::Instant::now();
let outcome = tokio::time::timeout(
std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS * 4),
drm_write_all(&writer, &payload, None),
)
.await;
drip.abort();
let inner = outcome.expect(
"the send deadline did not fire: the budget is being re-armed per readiness wait",
);
let err = inner.err().expect("a dripping peer must not complete the write");
assert!(
err.to_string().contains("did not accept the remaining"),
"unexpected error: {err}"
);
assert!(
started.elapsed() < std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS * 3),
"took {:?}, which is not the send deadline firing",
started.elapsed()
);
}
#[tokio::test]
async fn surplus_fds_keep_only_the_first() {
let (mut a, b) = tokio::net::UnixStream::pair().unwrap();
let mut rx = DrmConn::new(b);
let (rd, wr) = pipe();
let (rd2, _wr2) = pipe();
let payload = serde_json::to_vec(&Data::DrmFrame {
width: 8,
height: 8,
plane_rotation: None,
cursor_pos: None,
})
.unwrap();
let prefix = (payload.len() as u32).to_be_bytes();
let n = unsafe { send_with_fds(a.as_raw_fd(), &prefix, &[rd.as_raw_fd(), rd2.as_raw_fd()]) };
assert!(n >= 0, "sendmsg failed: {}", std::io::Error::last_os_error());
a.write_all(&payload).await.unwrap();
let (data, fd) = rx.recv_msg().await.unwrap();
assert!(matches!(
data,
Data::DrmFrame {
width: 8,
height: 8,
plane_rotation: None,
cursor_pos: None
}
));
let kept = fd.expect("the first surplus fd must be kept");
let sentinel = [0x5Au8];
assert_eq!(
unsafe { libc::write(wr.as_raw_fd(), sentinel.as_ptr() as *const libc::c_void, 1) },
1
);
let mut got = [0u8; 1];
assert_eq!(
unsafe { libc::read(kept.as_raw_fd(), got.as_mut_ptr() as *mut libc::c_void, 1) },
1
);
assert_eq!(got[0], 0x5A, "the kept fd must be the FIRST one sent");
}
// 16 fds need CMSG_LEN(64)=80 > the 64-byte DRM_CMSG_CAP, so the kernel sets MSG_CTRUNC.
#[tokio::test]
async fn rejects_truncated_control_message() {
let (a, b) = tokio::net::UnixStream::pair().unwrap();
let mut rx = DrmConn::new(b);
let (rd, _wr) = pipe();
let dups: Vec<OwnedFd> = (0..16).map(|_| rd.try_clone().unwrap()).collect();
let fds: Vec<libc::c_int> = dups.iter().map(|f| f.as_raw_fd()).collect();
let prefix = 0u32.to_be_bytes(); // the fds ride the prefix read; CTRUNC fires before any body
let n = unsafe { send_with_fds(a.as_raw_fd(), &prefix, &fds) };
assert!(n >= 0, "sendmsg failed: {}", std::io::Error::last_os_error());
let err = rx
.recv_msg()
.await
.err()
.expect("a truncated control message must be rejected");
let msg = err.to_string().to_lowercase();
assert!(
msg.contains("truncat") || msg.contains("ctrunc"),
"unexpected error: {err}"
);
}
#[test]
fn peer_uid_from_fd_reads_socket_peer() {
let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
let euid = unsafe { libc::geteuid() };
assert_eq!(peer_uid_from_fd(a.as_raw_fd()), Some(euid));
}
#[test]
fn drm_peer_authorized_matrix() {
assert!(drm_peer_authorized(Some(0), Some(1000)));
@@ -2213,24 +1993,4 @@ mod drm_conn_tests {
assert!(super::ipc_auth::ensure_peer_executable_matches_current_by_pid_opt(None, "_drm").is_err());
}
#[test]
fn drm_conn_admission_bound() {
assert!(drm_conn_admitted(0));
assert!(drm_conn_admitted(MAX_DRM_CONNS - 1)); // last admitted slot
assert!(!drm_conn_admitted(MAX_DRM_CONNS)); // cap reached -> rejected
assert!(!drm_conn_admitted(MAX_DRM_CONNS + 5)); // over cap -> rejected
}
#[test]
fn drm_auth_admission_bound() {
assert!(drm_auth_admitted(0));
assert!(drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT - 1)); // last admitted slot
assert!(!drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT)); // cap reached -> rejected
assert!(!drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT + 5)); // over cap -> rejected
assert!(
MAX_DRM_AUTH_IN_FLIGHT <= MAX_DRM_CONNS,
"the pre-auth bound must not be looser than the connection cap"
);
}
}
-44
View File
@@ -982,48 +982,4 @@ mod tests {
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn test_ensure_secure_ipc_parent_dir_marks_non_service_mode_repair_for_scrub() {
use std::os::unix::fs::PermissionsExt;
let unique = format!(
"rustdesk-ipc-nonservice-mode-repair-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let base = std::env::temp_dir().join(unique);
std::fs::create_dir_all(&base).unwrap();
let parent_dir = base.join("non_service_parent");
std::fs::create_dir_all(&parent_dir).unwrap();
std::fs::set_permissions(&parent_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
let ipc_file = parent_dir.join("ipc");
std::fs::write(&ipc_file, b"socket-placeholder").unwrap();
let res = super::ensure_secure_ipc_parent_dir(ipc_file.to_string_lossy().as_ref(), "");
assert_eq!(res.unwrap(), true);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn test_should_scrub_parent_entries_after_check_pid_only_when_requested_and_not_alive() {
assert!(!super::should_scrub_parent_entries_after_check_pid(
false, false
));
assert!(!super::should_scrub_parent_entries_after_check_pid(
false, true
));
assert!(super::should_scrub_parent_entries_after_check_pid(
true, false
));
assert!(!super::should_scrub_parent_entries_after_check_pid(
true, true
));
}
}
-36
View File
@@ -275,40 +275,4 @@ mod tests {
}
assert_eq!(got, N, "graceful close lost frames");
}
// Socket errors on the connected UDP socket (ICMP unreachable after the peer
// vanishes) are advisory: the io loop must treat them as loss - keep accepting
// writes, keep running - rather than tearing the session down. Whether the OS
// actually surfaces ECONNREFUSED here is platform-dependent; either way the
// session must stay alive for this window.
#[tokio::test]
async fn test_kcp_io_treats_socket_errors_as_loss() {
let ((_guard_a, mut stream_a), (guard_b, stream_b)) = establish().await;
// Kill the peer entirely: endpoint stops, socket closes.
drop(stream_b);
drop(guard_b);
tokio::time::sleep(Duration::from_millis(50)).await;
for _ in 0..10 {
stream_a
.send_bytes(Bytes::from_static(b"into the void"))
.await
.expect("socket errors must be treated as loss, not stream failure");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
// The connect deadline must hold when nothing answers: no hang, prompt error.
#[tokio::test]
async fn test_kcp_connect_timeout_without_peer() {
let (a, _b) = connected_pair().await;
let start = tokio::time::Instant::now();
let res = KcpStream::connect(a, Duration::from_millis(600)).await;
assert!(res.is_err(), "connect must fail with no peer endpoint");
assert!(
start.elapsed() < Duration::from_secs(5),
"connect did not honor its deadline"
);
}
}
-18
View File
@@ -336,16 +336,6 @@ mod test {
);
}
#[test]
fn test_resolve_lang_forces_english_for_saved_cjk_when_target_disables_cjk() {
use super::resolve_lang as f;
assert_eq!(f("zh-cn", "en-US", true), "en");
assert_eq!(f("zh-tw", "en-US", true), "en");
assert_eq!(f("ja", "en-US", true), "en");
assert_eq!(f("ko", "en-US", true), "en");
}
#[test]
fn test_resolve_lang_forces_english_for_cjk_locale_when_target_disables_cjk() {
use super::resolve_lang as f;
@@ -375,12 +365,4 @@ mod test {
assert_eq!(f("", "pt-AO", false), "pt-pt");
assert_eq!(f("", "pt-MZ", false), "pt-pt");
}
#[test]
fn test_resolve_lang_saved_lang_takes_precedence_over_locale() {
use super::resolve_lang as f;
assert_eq!(f("pt-br", "pt-PT", false), "pt-br");
assert_eq!(f("pt-pt", "pt-BR", false), "pt-pt");
}
}
-19
View File
@@ -183,25 +183,6 @@ mod tests {
);
}
#[test]
fn rejects_partial_reordered_or_extra_macos_update_arguments() {
for invalid in [
args(&["--update", "update.zip"]),
args(&["--update", "update.dmg", "--metadata", "update.json"]),
args(&[
"--update",
"update.dmg",
"--signature",
"update.sig",
"--metadata",
"update.json",
]),
args(&["--update", "update.dmg", "unexpected"]),
] {
assert!(parse_macos_update_args(&invalid).is_err(), "{invalid:?}");
}
}
#[test]
fn resolves_public_and_custom_update_package_identity() {
assert_eq!(
-36
View File
@@ -251,19 +251,6 @@ mod gnome_monitor_layout_tests {
Some(GnomeMonitorLayoutMode::Logical)
);
}
#[test]
fn maps_physical_layout() {
assert_eq!(
gnome_monitor_layout_mode_from_value(2),
Some(GnomeMonitorLayoutMode::Physical)
);
}
#[test]
fn rejects_unknown_layout() {
assert_eq!(gnome_monitor_layout_mode_from_value(4), None);
}
}
#[inline]
@@ -2467,29 +2454,6 @@ mod desktop {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_desktop_env() {
let mut d = Desktop::default();
d.refresh();
if d.username == "root" {
assert_eq!(d.home, "/root");
} else {
if !d.username.is_empty() {
let home = super::super::get_env_var("HOME");
if !home.is_empty() {
assert_eq!(d.home, home);
} else {
//
}
}
}
}
}
}
/// A session-bus idle-inhibit interface, tried in order; the first that answers wins.
-17
View File
@@ -228,21 +228,4 @@ mod tests {
std::fs::remove_dir_all(test_dir).unwrap();
}
#[test]
fn stale_update_temp_dir_name_requires_owned_randomized_dir() {
let euid = unsafe { hbb_common::libc::geteuid() };
assert!(is_stale_update_temp_dir_name(&format!(
".rustdeskupdate-{euid}-123-456"
)));
for name in [
format!(".rustdeskupdate-{euid}"),
format!(".rustdeskupdate-{euid}-abc-456"),
format!(".rustdeskupdate-{euid}-123"),
".rustdeskupdate-999999-123-456".to_owned(),
"rustdeskupdate-123-456".to_owned(),
] {
assert!(!is_stale_update_temp_dir_name(&name), "{name}");
}
}
}
+2 -47
View File
@@ -1,7 +1,7 @@
use super::{
verified_dmg::{
copy_and_verify_dmg_file, create_dmg_mount_point, extract_dmg_inner, verified_dmg_path,
verify_stored_dmg, VerifiedDmg,
copy_and_verify_dmg_file, extract_dmg_inner, verified_dmg_path, verify_stored_dmg,
VerifiedDmg,
},
verified_dmg_update_source, ResultType,
};
@@ -44,20 +44,6 @@ fn extract_verified_dmg(verified_dmg: &VerifiedDmg, target_dir: &str) -> ResultT
extract_dmg_inner(verified_dmg_path(verified_dmg)?, target_dir)
}
#[test]
fn update_dmg_mount_points_are_unique() {
let first = create_dmg_mount_point().unwrap();
let second = create_dmg_mount_point().unwrap();
let are_unique = first != second;
for mount_point in [&first, &second] {
if mount_point.starts_with("/tmp/.rustdeskmount-") {
std::fs::remove_dir(mount_point).unwrap();
}
}
assert!(are_unique);
}
#[test]
fn verified_dmg_extracts_from_named_copy() {
let test_dir = std::env::temp_dir().join(format!(
@@ -175,34 +161,3 @@ fn verified_dmg_rejects_symlink() {
.to_string()
.contains("not a regular file"));
}
#[test]
fn verified_dmg_handle_survives_path_replacement() {
use std::io::{Read as _, Seek as _};
let test_dir = std::env::temp_dir().join(format!(
"rustdesk-verified-dmg-handle-test-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&test_dir);
std::fs::create_dir_all(&test_dir).unwrap();
let file_path = test_dir.join("update.dmg");
let original_path = test_dir.join("original.dmg");
std::fs::write(&file_path, b"rustdesk").unwrap();
let mut verified_dmg = copy_and_verify_dmg_file(
&file_path.to_string_lossy(),
"304ca1638c5effa6832e0e15b958a8f74847efe4df9c3f3187216e921c168fed",
Some(8),
)
.unwrap();
std::fs::rename(&file_path, &original_path).unwrap();
std::fs::write(&file_path, b"tampered").unwrap();
verified_dmg.file.seek(std::io::SeekFrom::Start(0)).unwrap();
let mut contents = Vec::new();
verified_dmg.file.read_to_end(&mut contents).unwrap();
std::fs::remove_dir_all(test_dir).unwrap();
assert_eq!(contents, b"rustdesk");
}
-14
View File
@@ -15,18 +15,4 @@ mod tests {
assert_eq!(mobile_wheel_delta((2, -3), "Y"), (-2, 3));
assert_eq!(mobile_wheel_delta((0, 4), "Y"), (0, -4));
}
#[test]
fn preserves_both_axes_when_disabled() {
assert_eq!(mobile_wheel_delta((2, -3), "N"), (2, -3));
assert_eq!(mobile_wheel_delta((2, -3), ""), (2, -3));
}
#[test]
fn reversing_minimum_delta_does_not_overflow() {
assert_eq!(
mobile_wheel_delta((i32::MIN, i32::MIN), "Y"),
(i32::MAX, i32::MAX)
);
}
}
+1 -43
View File
@@ -236,11 +236,10 @@ pub fn get_pids_of_process_with_first_arg<S1: AsRef<str>, S2: AsRef<str>>(
}
}
#[cfg(test)]
#[cfg(all(test, any(windows, target_os = "macos")))]
mod tests {
use super::*;
#[cfg(any(windows, target_os = "macos"))]
#[test]
fn install_app_names_enforce_ascii_command_safety() {
for app_name in [
@@ -263,45 +262,4 @@ mod tests {
#[cfg(target_os = "macos")]
assert!(validate_install_app_name("-RustDesk").is_err());
}
#[test]
fn test_cursor_data() {
for _ in 0..30 {
if let Some(hc) = get_cursor().unwrap() {
let cd = get_cursor_data(hc).unwrap();
repng::encode(
std::fs::File::create("cursor.png").unwrap(),
cd.width as _,
cd.height as _,
&cd.colors[..],
)
.unwrap();
}
#[cfg(target_os = "macos")]
macos::is_process_trusted(false);
}
// A macOS capture takes the cursor shown, whatever seed it is asked for.
#[cfg(target_os = "macos")]
{
macos::reset_input_cache();
if let Some(change) = get_cursor().unwrap() {
assert!(get_cursor_data(change.wrapping_add(1)).is_ok());
}
}
}
#[test]
fn test_get_cursor_pos() {
for _ in 0..30 {
assert!(!get_cursor_pos().is_none());
}
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[test]
fn test_resolution() {
let name = r"\\.\DISPLAY1";
println!("current:{:?}", current_resolution(name));
println!("change:{:?}", change_resolution(name, 2880, 1800));
println!("resolutions:{:?}", resolutions(name));
}
}
-113
View File
@@ -4754,119 +4754,6 @@ pub(super) fn get_pids_with_first_arg_by_wmic<S1: AsRef<str>, S2: AsRef<str>>(
mod tests {
use super::*;
// Test-only reusable Win32 HANDLE RAII helper.
// If a future non-test path needs the same pattern, move it out of this test module.
//
// This struct is similar to `base::platform::windows::RAIIHandle`,
// but `RAIIHandle` depends on `WinApi` crate, while this `HandleGuard` only depends on `windows` crate.
struct HandleGuard(WinHANDLE);
impl HandleGuard {
#[inline]
fn new(handle: WinHANDLE) -> Self {
Self(handle)
}
#[inline]
fn get(&self) -> WinHANDLE {
self.0
}
}
impl Drop for HandleGuard {
fn drop(&mut self) {
unsafe {
if !self.0.is_invalid() {
let _ = WinCloseHandle(self.0);
}
}
}
}
#[test]
fn test_is_process_running_as_system_invalid_pid_errors() {
assert!(is_process_running_as_system(u32::MAX).is_err());
}
#[test]
fn test_is_process_running_as_system_matches_current_process_token_user() {
let pid = unsafe { windows::Win32::System::Threading::GetCurrentProcessId() };
let actual = is_process_running_as_system(pid).unwrap();
let expected = unsafe {
// Keep this test consistent: use only the `windows` crate APIs/types.
let process = HandleGuard::new(
WinOpenProcess(WIN_PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
.expect("WinOpenProcess should succeed for current process"),
);
let mut token = WinHANDLE::default();
WinOpenProcessToken(process.get(), WIN_TOKEN_QUERY, &mut token)
.expect("WinOpenProcessToken should succeed for current process");
let token = HandleGuard::new(token);
let mut token_user_size = 0u32;
let _ = WinGetTokenInformation(token.get(), TokenUser, None, 0, &mut token_user_size);
assert_ne!(token_user_size, 0, "TokenUser size should be non-zero");
let mut buffer = vec![0u8; token_user_size as usize];
WinGetTokenInformation(
token.get(),
TokenUser,
Some(buffer.as_mut_ptr() as *mut core::ffi::c_void),
token_user_size,
&mut token_user_size,
)
.expect("WinGetTokenInformation(TokenUser) should succeed for current process");
let min_size = std::mem::size_of::<TOKEN_USER>();
assert!(
buffer.len() >= min_size,
"TokenUser buffer too small (got {}, need >= {})",
buffer.len(),
min_size
);
let token_user: TOKEN_USER =
std::ptr::read_unaligned(buffer.as_ptr() as *const TOKEN_USER);
let expected = IsWellKnownSid(token_user.User.Sid, WinLocalSystemSid).as_bool();
expected
};
assert_eq!(actual, expected);
}
#[test]
fn test_uninstall_cert() {
println!("uninstall driver certs: {:?}", cert::uninstall_cert());
}
#[test]
fn test_get_unicode_char_by_vk() {
let chr = get_char_from_vk(0x41); // VK_A
assert_eq!(chr, Some('a'));
let chr = get_char_from_vk(VK_ESCAPE as u32); // VK_ESC
assert_eq!(chr, None)
}
#[test]
fn install_app_names_enforce_ascii_command_safety() {
assert!(validate_install_app_name("RustDesk-Admin1").is_ok());
for app_name in [
"",
"RustDesk_Admin",
"RustDesk&whoami",
"RustDesk应用",
"RustDesk앱",
"RustDeskBüro",
"RustDeskFrançais",
"RustDeskアプリ",
] {
assert!(
validate_install_app_name(app_name).is_err(),
"unsafe application name was accepted: {app_name}"
);
}
}
#[test]
fn vbs_files_use_utf16le_with_bom_and_crlf() {
const EXPECTED: &[u8] = &[0xFF, 0xFE, b'a', 0, b'\r', 0, b'\n', 0, b'b', 0];
-28
View File
@@ -785,34 +785,6 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[test]
fn test_portable_service_shmem_dir_acl_rejects_file_target() {
let dir = unique_acl_test_path("dir_target_file");
fs::create_dir_all(&dir).unwrap();
let file = dir.join("target.txt");
fs::write(&file, b"x").unwrap();
let result = set_path_permission_for_portable_service_shmem_dir(&file);
assert!(result.is_err());
let _ = fs::remove_file(&file);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_portable_service_shmem_file_acl_rejects_dir_target() {
let dir = unique_acl_test_path("file_target_dir");
fs::create_dir_all(&dir).unwrap();
let result = set_path_permission_for_portable_service_shmem_file(&dir);
assert!(result.is_err());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_portable_service_shmem_file_acl_rejects_missing_target() {
let path = unique_acl_test_path("missing").join("shared_memory_missing");
let result = set_path_permission_for_portable_service_shmem_file(&path);
assert!(result.is_err());
}
#[test]
fn test_set_path_permission_rejects_reparse_entrypoint() {
let root = unique_acl_test_path("reparse_entry");
-40
View File
@@ -219,43 +219,3 @@ fn schedule_file_removal_on_reboot(path: &Path) -> ResultType<()> {
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schedules_cleanup_for_verified_exe_only() {
assert!(should_schedule_verified_update_cleanup(Path::new(
r"C:\Temp\rustdesk-verified-123-456.exe"
)));
for path in [
r"C:\Temp\rustdesk-verified-123-456.msi",
r"C:\Temp\rustdesk.exe",
r"C:\Temp\rustdesk-verified-123-456.exe.bak",
] {
assert!(!should_schedule_verified_update_cleanup(Path::new(path)));
}
}
#[test]
fn failed_verified_update_launch_clears_staged_custom_client_file() {
let staging_dir = std::env::temp_dir().join(format!(
"rustdesk-update-staging-cleanup-test-{}-{}",
std::process::id(),
hbb_common::rand::random::<u64>()
));
std::fs::create_dir_all(&staging_dir).unwrap();
let staged_file = staging_dir.join("custom.txt");
std::fs::write(&staged_file, b"custom client").unwrap();
let successful_launch: ResultType<()> = Ok(());
clear_custom_client_staging_after_launch_failure(&staging_dir, &successful_launch);
assert!(staged_file.exists());
let launch_result: ResultType<()> = Err(hbb_common::anyhow::anyhow!("launch failed"));
clear_custom_client_staging_after_launch_failure(&staging_dir, &launch_result);
assert!(!staged_file.exists());
std::fs::remove_dir_all(staging_dir).unwrap();
}
}
-106
View File
@@ -733,59 +733,6 @@ mod login_tests {
});
}
#[test]
fn a_password_typed_before_this_connections_hash_answers_it_when_it_comes() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let ui = window();
let (mut b, mut b_peer) = loopback().await;
// The prompt's password reached B before its hash, and no other
// mapping has stored it in the handler yet.
let typed = (String::new(), String::new(), "pw".to_owned(), false);
assert!(hash_arrived(&ui, "", hash("b"), Some(typed), "b", 2, false, &mut b).await);
let lr = login_at(&mut b_peer).await;
assert_eq!(lr.password, digest("b"));
assert_eq!(target(&lr), ("b".to_owned(), 2));
});
}
#[test]
fn a_raw_pipe_login_on_a_multiplexed_window_does_not_ask_for_the_tunnel() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let ui = window();
// The window probes for the tunnel, but this mapping latched to
// the raw pipe: its login must read as the raw pipe's, or an
// upgraded peer answers with a tunnel it then never gets.
ui.lc.write().unwrap().port_forward_mux = true;
let (mut a, mut a_peer) = loopback().await;
assert!(login_with_hash(&ui, "pw", hash("a"), "a", 1, false, &mut a).await);
assert!(!login_at(&mut a_peer).await.port_forward().multiplex);
});
}
#[test]
fn a_password_typed_at_the_prompt_keeps_a_raw_pipe_login_raw() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let ui = window();
ui.lc.write().unwrap().port_forward_mux = true;
let (mut b, mut b_peer) = loopback().await;
let typed = (String::new(), String::new(), "pw".to_owned(), false);
login_from_ui(&ui, &hash("b"), typed, "b", 2, false, &mut b).await;
assert!(!login_at(&mut b_peer).await.port_forward().multiplex);
});
}
#[test]
fn a_probing_login_asks_for_the_tunnel() {
let rt = tokio::runtime::Builder::new_current_thread()
@@ -800,56 +747,3 @@ mod login_tests {
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn peer_supports_mux_reads_the_features_bit() {
let mut pi = PeerInfo::new();
assert!(!peer_supports_mux(&pi));
pi.features = Some(Features { port_forward_mux: false, ..Default::default() }).into();
assert!(!peer_supports_mux(&pi));
pi.features = Some(Features { port_forward_mux: true, ..Default::default() }).into();
assert!(peer_supports_mux(&pi));
}
#[test]
fn port_forward_mux_defaults_to_on() {
use hbb_common::config::option2bool;
use base::config::keys;
// option2bool's fallback branch is also "on unless N", so the value
// assertions below would pass for a prefixless key too. The `enable-`
// prefix is what actually guarantees the default, and renaming the key
// to an `allow-` one would silently flip it — pin the prefix itself.
assert!(keys::OPTION_ENABLE_PORT_FORWARD_MUX.starts_with("enable-"));
assert!(option2bool(keys::OPTION_ENABLE_PORT_FORWARD_MUX, ""));
assert!(option2bool(keys::OPTION_ENABLE_PORT_FORWARD_MUX, "Y"));
assert!(!option2bool(keys::OPTION_ENABLE_PORT_FORWARD_MUX, "N"));
}
#[test]
fn take_socket_hands_back_a_working_socket_and_the_prebuf() {
use hbb_common::tokio::io::{AsyncReadExt, AsyncWriteExt};
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
rt.block_on(async {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
let mut client = TcpStream::connect(addr).await.unwrap();
let (server, _) = l.accept().await.unwrap();
let mut framed = Framed::new(server, BytesCodec::new());
client.write_all(b"abc").await.unwrap();
// Read through the codec, as connect_and_login does during login.
let pulled = framed.next().await.unwrap().unwrap();
assert_eq!(&pulled[..], b"abc");
let (mut sock, prebuf) = take_socket(framed, pulled.to_vec());
assert_eq!(prebuf, b"abc".to_vec());
// Bytes written after the handoff arrive on the bare socket.
client.write_all(b"def").await.unwrap();
let mut buf = [0u8; 3];
sock.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"def");
});
}
}
+8 -411
View File
@@ -84,11 +84,6 @@ impl RecvWindow {
self.remaining = self.remaining.saturating_add(n);
}
#[cfg(test)]
pub fn remaining(&self) -> u32 {
self.remaining
}
/// Returns the amount to advertise in a `window_update` once enough has
/// been drained; the same amount is credited back.
pub fn drained(&mut self, n: usize) -> Option<u32> {
@@ -438,7 +433,7 @@ mod tunnel {
const REPORT_AGAIN_AFTER: Duration = Duration::from_secs(10);
/// Distinct reasons remembered at once, so the dialog count stays bounded
/// however the peer varies the text it sends.
pub(super) const MAX_REPORTED_OPEN_ERRORS: usize = 8;
const MAX_REPORTED_OPEN_ERRORS: usize = 8;
// Internal state only; `Claim` is the API listeners see.
enum TunnelState {
@@ -661,7 +656,7 @@ mod tunnel {
self.first_report_at(message, Instant::now())
}
pub(super) fn first_report_at(&self, message: String, now: Instant) -> Option<String> {
fn first_report_at(&self, message: String, now: Instant) -> Option<String> {
if message.is_empty() {
return None;
}
@@ -689,11 +684,6 @@ mod tunnel {
pub fn live_channels(&self) -> usize {
self.channels.lock().unwrap().len()
}
#[cfg(test)]
pub fn set_next_id(&self, id: i32) {
self.next_id.store(id, Ordering::Relaxed);
}
}
/// The only task that touches the stream. The three arms keep tokio's
@@ -768,23 +758,6 @@ mod tests {
.unwrap()
}
#[test]
fn effective_window_clamps_to_initial() {
assert_eq!(effective_window(0), INITIAL_WINDOW);
assert_eq!(effective_window(INITIAL_WINDOW - 1), INITIAL_WINDOW);
assert_eq!(effective_window(INITIAL_WINDOW), INITIAL_WINDOW);
assert_eq!(effective_window(CHANNEL_WINDOW), CHANNEL_WINDOW);
}
#[test]
fn recv_window_rejects_over_window_data() {
// `new` clamps its grant to INITIAL_WINDOW, so the test must fill that.
let mut w = RecvWindow::new(INITIAL_WINDOW);
assert!(w.accept(INITIAL_WINDOW as usize - MIN_FRAME_CHARGE as usize));
assert!(w.accept(MIN_FRAME_CHARGE as usize));
assert!(!w.accept(1));
}
#[test]
fn tiny_frames_are_charged_at_the_minimum() {
let mut w = RecvWindow::new(INITIAL_WINDOW);
@@ -795,20 +768,6 @@ mod tests {
assert!(!w.accept(1));
}
#[test]
fn recv_window_updates_only_past_threshold() {
let mut w = RecvWindow::new(CHANNEL_WINDOW);
assert_eq!(
w.drained(UPDATE_THRESHOLD as usize - MIN_FRAME_CHARGE as usize),
None
);
assert_eq!(w.drained(MIN_FRAME_CHARGE as usize), Some(UPDATE_THRESHOLD));
// The update re-grants what was drained, so the same amount is accepted again.
assert!(w.accept(CHANNEL_WINDOW as usize));
assert!(w.accept(UPDATE_THRESHOLD as usize));
assert!(!w.accept(1));
}
#[test]
fn accounting_survives_a_transfer_far_larger_than_the_window() {
// Cumulative counters used to overflow around 4 GiB on one channel and
@@ -822,16 +781,6 @@ mod tests {
}
}
#[test]
fn grant_extends_a_window_that_has_been_used_up() {
let mut w = RecvWindow::new(INITIAL_WINDOW);
assert!(w.accept(INITIAL_WINDOW as usize));
assert!(!w.accept(1));
w.grant(CHANNEL_WINDOW - INITIAL_WINDOW);
assert!(w.accept((CHANNEL_WINDOW - INITIAL_WINDOW) as usize));
assert!(!w.accept(1));
}
#[test]
fn send_credit_blocks_at_zero_and_resumes_on_add() {
rt().block_on(async {
@@ -890,45 +839,17 @@ mod tests {
});
}
#[test]
fn frame_builders_set_the_expected_union_variant() {
use base::message_proto::{message, port_forward_channel};
let m = data_msg(7, Bytes::from_static(b"abc"));
match m.union {
Some(message::Union::PortForwardChannel(ch)) => match ch.union {
Some(port_forward_channel::Union::Data(d)) => {
assert_eq!(d.channel_id, 7);
assert_eq!(d.data, b"abc".to_vec());
}
other => panic!("unexpected {:?}", other),
},
other => panic!("unexpected {:?}", other),
}
let m = opened_msg(3, false, "nope", CHANNEL_WINDOW);
match m.union {
Some(message::Union::PortForwardChannel(ch)) => match ch.union {
Some(port_forward_channel::Union::Opened(o)) => {
assert_eq!((o.channel_id, o.success, o.message.as_str(), o.window),
(3, false, "nope", CHANNEL_WINDOW));
}
other => panic!("unexpected {:?}", other),
},
other => panic!("unexpected {:?}", other),
}
}
use base::message_proto::{message, port_forward_channel};
use hbb_common::tokio::{self, io::AsyncReadExt, io::AsyncWriteExt, sync::mpsc};
use std::sync::{Arc, Mutex};
struct Harness {
data_rx: mpsc::Receiver<Message>,
control_rx: mpsc::UnboundedReceiver<Message>,
inbound_tx: mpsc::UnboundedSender<Inbound>,
_control_rx: mpsc::UnboundedReceiver<Message>,
_inbound_tx: mpsc::UnboundedSender<Inbound>,
credit: Arc<SendCredit>,
window: Arc<Mutex<RecvWindow>>,
local: tokio::io::DuplexStream,
teardown: watch::Sender<bool>,
_teardown: watch::Sender<bool>,
task: tokio::task::JoinHandle<()>,
}
@@ -938,8 +859,6 @@ mod tests {
harness_on(id, prebuf, initial_out, watch::channel(false).0)
}
/// The channel subscribes to `teardown` here, so a test can hand in one
/// that has already been raised.
fn harness_on(id: i32, prebuf: Vec<u8>, initial_out: Vec<Bytes>, teardown: watch::Sender<bool>) -> Harness {
let (data_tx, data_rx) = mpsc::channel(DATA_QUEUE_FRAMES);
let (control_tx, control_rx) = mpsc::unbounded_channel();
@@ -951,9 +870,9 @@ mod tests {
let sink = FrameSink::Queued { data: data_tx, control: control_tx };
let teardown_rx = teardown.subscribe();
let task = tokio::spawn(run_channel(
id, r, w, prebuf, initial_out, credit.clone(), window.clone(), inbound_rx, sink, teardown_rx,
id, r, w, prebuf, initial_out, credit.clone(), window, inbound_rx, sink, teardown_rx,
));
Harness { data_rx, control_rx, inbound_tx, credit, window, local, teardown, task }
Harness { data_rx, _control_rx: control_rx, _inbound_tx: inbound_tx, credit, local, _teardown: teardown, task }
}
// `PortForwardData.data` is generated as `bytes::Bytes` (hbb_common builds
@@ -979,74 +898,6 @@ mod tests {
}
}
#[test]
fn teardown_ends_a_channel_parked_on_a_socket_nobody_reads() {
rt().block_on(async {
// Nothing reads the local side, so once the duplex buffer is full
// the socket relay parks in write_all; nothing writes it either,
// so the tunnel relay parks in read. Neither is on the inbound
// queue, which stays open here: teardown alone must end them.
let mut h = harness(1, vec![], vec![]);
for _ in 0..17 {
h.inbound_tx.send(Inbound::Data(Bytes::from(vec![0u8; MAX_FRAME]))).unwrap();
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
h.teardown.send_replace(true);
let ended = tokio::time::timeout(std::time::Duration::from_millis(500), &mut h.task).await;
assert!(ended.is_ok(), "channel task outlived the tunnel");
});
}
#[test]
fn a_channel_subscribed_after_teardown_ends_at_once() {
rt().block_on(async {
// `open` can race `close_all`: this channel subscribes after the
// signal went out, and its entry sits in a map that was already
// cleared, so nothing will ever drop its inbound sender. No other
// channel was live when the tunnel closed, either.
let teardown = watch::channel(false).0;
teardown.send_replace(true);
let mut h = harness_on(1, vec![], vec![], teardown);
let ended = tokio::time::timeout(std::time::Duration::from_millis(500), &mut h.task).await;
assert!(ended.is_ok(), "late channel outlived the tunnel");
});
}
#[test]
fn local_bytes_become_data_frames_capped_at_max_frame() {
rt().block_on(async {
let mut h = harness(1, vec![], vec![]);
let payload = vec![7u8; MAX_FRAME + 10];
h.local.write_all(&payload).await.unwrap();
// INITIAL_WINDOW equals MAX_FRAME, so the first frame exhausts
// it exactly; grant one minimum charge for the 10-byte tail.
h.credit.add(MIN_FRAME_CHARGE);
let mut got = Vec::new();
while got.len() < payload.len() {
let m = h.data_rx.recv().await.unwrap();
let (kind, id, bytes) = frame_kind(&m);
assert_eq!((kind, id), ("data", 1));
assert!(bytes.len() <= MAX_FRAME);
got.extend(bytes);
}
assert_eq!(got, payload);
});
}
#[test]
fn prebuf_is_the_head_of_the_send_stream() {
rt().block_on(async {
let mut h = harness(2, b"head".to_vec(), vec![]);
h.local.write_all(b"tail").await.unwrap();
let mut got = Vec::new();
while got.len() < 8 {
let m = h.data_rx.recv().await.unwrap();
got.extend(frame_kind(&m).2);
}
assert_eq!(got, b"headtail".to_vec());
});
}
#[test]
fn send_side_stops_at_credit_and_resumes_on_add() {
rt().block_on(async {
@@ -1070,27 +921,6 @@ mod tests {
});
}
#[test]
fn inbound_data_is_written_and_window_update_follows_threshold() {
rt().block_on(async {
let mut h = harness(4, vec![], vec![Bytes::from_static(b"first")]);
let mut buf = [0u8; 5];
h.local.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"first");
let chunk = Bytes::from(vec![9u8; UPDATE_THRESHOLD as usize]);
assert!(h.window.lock().unwrap().accept(chunk.len()));
h.inbound_tx.send(Inbound::Data(chunk.clone())).unwrap();
let mut sink = vec![0u8; chunk.len()];
h.local.read_exact(&mut sink).await.unwrap();
let m = h.control_rx.recv().await.unwrap();
let (kind, id, add) = frame_kind(&m);
assert_eq!((kind, id), ("window_update", 4));
let add = u32::from_le_bytes([add[0], add[1], add[2], add[3]]);
// The 5-byte `initial` chunk drained a whole minimum charge.
assert_eq!(add, UPDATE_THRESHOLD + MIN_FRAME_CHARGE);
});
}
#[test]
fn local_eof_sends_close_exactly_once_after_the_data() {
rt().block_on(async {
@@ -1104,41 +934,10 @@ mod tests {
});
}
#[test]
fn peer_close_ends_the_channel_without_echoing_close() {
rt().block_on(async {
let mut h = harness(6, vec![], vec![]);
h.inbound_tx.send(Inbound::Close).unwrap();
h.task.await.unwrap();
assert!(h.data_rx.try_recv().is_err());
assert!(h.control_rx.try_recv().is_err());
});
}
#[test]
fn violation_signalled_by_the_demux_sends_close() {
rt().block_on(async {
let mut h = harness(7, vec![], vec![]);
h.inbound_tx.send(Inbound::Violation).unwrap();
assert_eq!(frame_kind(&h.data_rx.recv().await.unwrap()), ("close", 7, vec![]));
h.task.await.unwrap();
});
}
#[test]
fn dropped_tunnel_ends_the_channel_silently() {
rt().block_on(async {
let mut h = harness(8, vec![], vec![]);
drop(h.inbound_tx);
h.task.await.unwrap();
assert!(h.data_rx.try_recv().is_err());
});
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
mod tunnel {
use super::*;
use crate::port_forward_mux::{tunnel::{TunnelHandle, MAX_REPORTED_OPEN_ERRORS}, Claim, Tunnel};
use crate::port_forward_mux::{tunnel::TunnelHandle, Claim, Tunnel};
use hbb_common::{
protobuf::Message as _,
tcp::FramedStream,
@@ -1199,42 +998,6 @@ mod tests {
});
}
#[test]
fn claim_follows_the_tunnel_state() {
let t = Tunnel::new();
assert!(matches!(t.claim(), Claim::Claimed));
t.set_failed();
assert!(matches!(t.claim(), Claim::Claimed));
t.set_legacy();
assert!(matches!(t.claim(), Claim::Legacy));
}
#[test]
fn an_id_still_live_when_the_counter_comes_round_is_skipped() {
rt().block_on(async {
let (ours, mut peer) = stream_pair().await;
let t = Tunnel::new();
t.claim();
let h = t.set_muxed(ours, NoUi::default());
let id_of = |ch: &PortForwardChannel| match &ch.union {
Some(port_forward_channel::Union::Open(o)) => o.channel_id,
other => panic!("expected open, got {:?}", other),
};
let (_a, sock_a) = local_pair().await;
h.open("localhost", 80, sock_a, vec![]).unwrap();
let a = id_of(&recv_frame(&mut peer).await);
// 2^32 opens later the counter is back at A's id, and A is
// still up. Handing that id out again would replace A's entry
// here while the peer keeps routing it to A's socket.
h.set_next_id(a);
let (_b, sock_b) = local_pair().await;
h.open("localhost", 80, sock_b, vec![]).unwrap();
let b = id_of(&recv_frame(&mut peer).await);
assert_ne!(b, a, "channel B was handed A's live id");
assert_eq!(h.live_channels(), 2);
});
}
#[test]
fn open_sends_open_then_pipelined_data_and_relays_replies() {
rt().block_on(async {
@@ -1295,65 +1058,6 @@ mod tests {
});
}
#[test]
fn every_open_precedes_its_own_channels_first_data() {
rt().block_on(async {
let (ours, mut peer) = stream_pair().await;
let t = Tunnel::new();
assert!(matches!(t.claim(), Claim::Claimed));
let h = t.set_muxed(ours, NoUi::default());
// Twenty channels, each with one byte of pipelined data behind
// its open. An open on the control queue can lose the loop's
// random tie-break to a data frame, so one channel would be a
// coin flip; twenty make a wrong implementation fail every run.
const N: usize = 20;
let mut apps = Vec::new();
for i in 0..N {
let (app, sock) = local_pair().await;
h.open("localhost", 1, sock, vec![i as u8]).unwrap();
apps.push(app);
}
let mut opened = std::collections::HashSet::new();
let mut seen_data = 0;
while seen_data < N {
let ch = recv_frame(&mut peer).await;
match &ch.union {
Some(port_forward_channel::Union::Open(o)) => {
assert!(opened.insert(o.channel_id), "duplicate open");
}
Some(port_forward_channel::Union::Data(d)) => {
assert!(
opened.contains(&d.channel_id),
"data for channel {} arrived before its open",
d.channel_id
);
seen_data += 1;
}
other => panic!("unexpected {:?}", other),
}
}
});
}
#[test]
fn failed_open_closes_the_local_socket() {
rt().block_on(async {
let (ours, mut peer) = stream_pair().await;
let t = Tunnel::new();
t.claim();
let h = t.set_muxed(ours, NoUi::default());
let (mut app, sock) = local_pair().await;
h.open("localhost", 1, sock, vec![]).unwrap();
let id = match recv_frame(&mut peer).await.union {
Some(port_forward_channel::Union::Open(o)) => o.channel_id,
other => panic!("expected open, got {:?}", other),
};
peer.send(&opened_msg(id, false, "refused", 0)).await.unwrap();
let mut buf = [0u8; 1];
assert_eq!(app.read(&mut buf).await.unwrap(), 0);
});
}
#[test]
fn a_refused_channel_is_reported_once_per_reason() {
rt().block_on(async {
@@ -1382,35 +1086,6 @@ mod tests {
});
}
#[test]
fn a_refused_reason_is_reported_again_after_a_quiet_spell() {
rt().block_on(async {
let (ours, _peer) = stream_pair().await;
let t = Tunnel::new();
t.claim();
let h = t.set_muxed(ours, NoUi::default());
let t0 = Instant::now();
let at = |secs: u64| t0 + std::time::Duration::from_secs(secs);
let down = || "down".to_owned();
assert_eq!(h.first_report_at(down(), at(0)), Some(down()));
// A burst that keeps going keeps the dialog quiet ...
assert_eq!(h.first_report_at(down(), at(8)), None);
assert_eq!(h.first_report_at(down(), at(16)), None);
// ... and one that stopped is reported afresh.
assert_eq!(h.first_report_at(down(), at(27)), Some(down()));
// The cap counts reasons still live, so it cannot silence the
// window for good.
for i in 0..MAX_REPORTED_OPEN_ERRORS {
h.first_report_at(format!("reason {}", i), at(27));
}
assert_eq!(h.first_report_at("one more".to_owned(), at(27)), None);
assert_eq!(
h.first_report_at("one more".to_owned(), at(40)),
Some("one more".to_owned())
);
});
}
#[test]
fn an_over_window_frame_drops_the_channel_at_once() {
rt().block_on(async {
@@ -1619,84 +1294,6 @@ mod tests {
});
}
#[test]
fn a_channel_opened_during_a_bulk_transfer_is_served_promptly() {
rt().block_on(async {
let (_t, h, port) = muxed_tunnel().await;
let (bulk_app, bulk_sock) = local_pair().await;
h.open("127.0.0.1", port as i32, bulk_sock, vec![]).unwrap();
let bulk = vec![0xAB; 4 << 20];
let (mut bulk_rd, mut bulk_wr) = bulk_app.into_split();
let bulk_writer = {
let bulk = bulk.clone();
tokio::spawn(async move {
bulk_wr.write_all(&bulk).await.unwrap();
// Holding the write half open: dropping it half-closes
// the socket, which ends the channel by design.
bulk_wr
})
};
// Wait until a mebibyte is back, so the bulk channel is
// demonstrably mid-flight before anything else is opened.
let mut back = vec![0u8; 1 << 20];
bulk_rd.read_exact(&mut back).await.unwrap();
let (mut app, sock) = local_pair().await;
h.open("127.0.0.1", port as i32, sock, vec![42]).unwrap();
let mut b = [0u8; 1];
tokio::time::timeout(std::time::Duration::from_secs(2), app.read_exact(&mut b))
.await
.expect("a channel opened during a bulk transfer starved")
.unwrap();
assert_eq!(b[0], 42);
let mut rest = vec![0u8; bulk.len() - (1 << 20)];
bulk_rd.read_exact(&mut rest).await.unwrap();
let _bulk_wr = bulk_writer.await.unwrap();
});
}
#[test]
fn a_local_half_close_ends_the_whole_channel() {
rt().block_on(async {
let (_t, h, port) = muxed_tunnel().await;
let (app, sock) = local_pair().await;
h.open("127.0.0.1", port as i32, sock, vec![]).unwrap();
let (mut rd, wr) = app.into_split();
// Dropping the write half is a shutdown(SHUT_WR). Supporting it
// needs a direction flag on the close frame; today's raw pipe
// drops both directions on either EOF too, and this matches it.
drop(wr);
let mut buf = [0u8; 1];
assert_eq!(rd.read(&mut buf).await.unwrap(), 0);
});
}
#[test]
fn tail_before_close_is_delivered_in_both_directions() {
rt().block_on(async {
let (_t, h, port) = muxed_tunnel().await;
let (app, sock) = local_pair().await;
h.open("127.0.0.1", port as i32, sock, vec![]).unwrap();
let payload = vec![7u8; 300 * 1024];
let (mut rd, mut wr) = app.into_split();
let reader = {
let payload = payload.clone();
tokio::spawn(async move {
let mut back = vec![0u8; payload.len()];
rd.read_exact(&mut back).await.unwrap();
assert_eq!(back, payload);
rd
})
};
wr.write_all(&payload).await.unwrap();
// The full echo proves every byte reached the target ahead of anything
// else; only then close, and the peer's `close` must follow cleanly.
let mut rd = reader.await.unwrap();
drop(wr);
let mut one = [0u8; 1];
assert_eq!(rd.read(&mut one).await.unwrap(), 0);
});
}
#[test]
fn one_byte_frames_never_trip_the_window() {
rt().block_on(async {
-11
View File
@@ -263,14 +263,3 @@ pub extern "system" fn privacy_mode_hook_mouse(
}
unsafe { CallNextHookEx(NULL as _, code, w_param, l_param) }
}
mod test {
#[test]
fn privacy_hook() {
//use super::*;
// privacy_hook::hook().unwrap();
// std::thread::sleep(std::time::Duration::from_millis(50));
// privacy_hook::unhook().unwrap();
}
}
+4 -300
View File
@@ -1616,7 +1616,7 @@ async fn punch_tcp_until_connected(
}
/// The repeats of `punch_tcp_until_connected`, over any punch rather than `connect_tcp_local`
/// alone, so that a test can run the schedule against a paused clock - which no socket can be.
/// alone.
async fn punch_until<T, F, Fut>(
until: tokio::time::Instant,
peer_addr: SocketAddr,
@@ -1719,26 +1719,16 @@ impl Drop for CheckIfResendPk {
#[cfg(test)]
mod tests {
use super::{
connection_meta, mpsc, socket_client, tokio, udp_nat_listen, AnswererSlot, Arc, IceRoute,
IntoTargetAddr, Ordering, PunchHoleSent, RendezvousMediator, RendezvousMessage,
ICE_DEDUP_WINDOW, MAX_PENDING_REMOTE_ICE, MAX_WEBRTC_ANSWERERS, TCP_PUNCHES, UDP_PUNCHES,
connection_meta, mpsc, tokio, AnswererSlot, IceRoute, IntoTargetAddr, PunchHoleSent,
RendezvousMediator, RendezvousMessage, MAX_WEBRTC_ANSWERERS, TCP_PUNCHES, UDP_PUNCHES,
};
use hbb_common::{protobuf::Message as _, tcp::new_listener};
use std::net::SocketAddr;
// The pools are statics and the tests run in parallel: the ones that count them take turns.
static POOLS: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn pools() -> std::sync::MutexGuard<'static, ()> {
POOLS.lock().unwrap_or_else(|e| e.into_inner())
}
use hbb_common::protobuf::Message as _;
// The places are the bound and they come back on drop: a punch past the limit is declined
// while the ones in flight are left alone, and the next one in is admitted only once a
// place has actually been given back.
#[test]
fn test_punch_slots_cap_and_release() {
let _pools = pools();
for pool in [&UDP_PUNCHES, &TCP_PUNCHES] {
let held: Vec<_> = (0..pool.max)
.map(|_| pool.take().expect("a place up to the limit"))
@@ -1749,34 +1739,6 @@ mod tests {
}
}
// Neither transport pays for the other's crowd: a full UDP pool leaves the TCP places alone.
#[test]
fn a_full_udp_pool_leaves_the_tcp_places_alone() {
let _pools = pools();
let held: Vec<_> = (0..UDP_PUNCHES.max)
.map(|_| UDP_PUNCHES.take().expect("a place up to the limit"))
.collect();
assert!(UDP_PUNCHES.take().is_none(), "the UDP pool is full");
assert!(
TCP_PUNCHES.take().is_some(),
"a TCP punch must not wait on the UDP pool"
);
drop(held);
}
// At the last place it is the v6 punch that goes without, never the v4 one the peer counts on.
#[test]
fn the_last_place_goes_to_the_v4_punch() {
let _pools = pools();
let held: Vec<_> = (0..UDP_PUNCHES.max - 1)
.map(|_| UDP_PUNCHES.take().expect("a place up to the last"))
.collect();
let (v4, v6) = UDP_PUNCHES.take_pair(true, true);
assert!(v4.is_some(), "the last place goes to the v4 punch");
assert!(v6.is_none(), "and the v6 punch goes without");
drop(held);
}
// Declined is the listen, never the reply: with no place the PunchHoleSent still goes out,
// carrying the answer and the v6 address the controller's other transports run on, and the
// call returns at once - a listen would have waited for the peer's probe and failed without.
@@ -1816,56 +1778,6 @@ mod tests {
assert_eq!(&sent.socket_addr_v6[..], b"v6");
}
// The place is the wait: a listen that ends without a peer gives it back on its own. The end
// a test can reach is the peer's probe never coming, which `punch_udp` gives up on.
#[test]
fn a_listen_that_finds_no_peer_gives_its_place_back() {
let _pools = pools();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
let silent = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
let peer = silent.local_addr().unwrap();
let before = UDP_PUNCHES.places.load(Ordering::Acquire);
let slot = UDP_PUNCHES.take().expect("a place");
assert_eq!(UDP_PUNCHES.places.load(Ordering::Acquire), before + 1);
let listened = udp_nat_listen(
Arc::new(socket),
peer,
peer,
crate::server::new_for_test(),
connection_meta(None, None),
slot,
)
.await;
assert!(listened.is_err(), "no peer, no session");
assert_eq!(
UDP_PUNCHES.places.load(Ordering::Acquire),
before,
"the place must come back with the wait"
);
});
}
// A SOCKS proxy makes `connect_tcp_local` dial the proxy and ignore the local address, so
// nothing these two assert can hold. Read once, from the same global config production reads.
fn proxied() -> bool {
hbb_common::config::Config::get_socks().is_some()
}
/// Both held while their addresses are read, so the pair cannot be the same port - which
/// `SO_REUSEPORT` would let bind twice rather than refuse, leaving the tests degenerate.
async fn free_loopback_pair() -> (SocketAddr, SocketAddr) {
let (a, b) = (
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(),
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(),
);
(a.local_addr().unwrap(), b.local_addr().unwrap())
}
fn queue(route: &mut IceRoute, candidate: &str) -> bool {
route.queue(candidate.to_owned())
}
@@ -1887,214 +1799,6 @@ mod tests {
assert_eq!(queued, vec!["host".to_owned(), "relay".to_owned()]);
}
#[test]
fn a_candidate_the_full_queue_refused_is_not_remembered() {
let (tx, mut rx) = mpsc::channel::<String>(1);
let mut route = IceRoute::new(tx);
assert!(queue(&mut route, "host"));
assert!(!queue(&mut route, "relay"));
// The re-send is the only repair for a refused candidate; remembering it would swallow it.
assert_eq!(rx.try_recv().ok(), Some("host".to_owned()));
assert!(queue(&mut route, "relay"));
assert_eq!(rx.try_recv().ok(), Some("relay".to_owned()));
}
#[test]
fn a_re_send_is_skipped_while_the_original_is_still_queued() {
let (tx, mut rx) = mpsc::channel::<String>(MAX_PENDING_REMOTE_ICE);
let mut route = IceRoute::new(tx);
for i in 0..MAX_PENDING_REMOTE_ICE {
assert!(queue(&mut route, &format!("candidate-{}", i)));
}
assert!(queue(&mut route, "candidate-0"));
let mut queued = 0;
while rx.try_recv().is_ok() {
queued += 1;
}
assert_eq!(queued, MAX_PENDING_REMOTE_ICE);
}
#[test]
fn the_window_forgets_in_arrival_order() {
let (tx, mut rx) = mpsc::channel::<String>(MAX_PENDING_REMOTE_ICE);
let mut route = IceRoute::new(tx);
for i in 0..=ICE_DEDUP_WINDOW {
assert!(queue(&mut route, &format!("candidate-{}", i)));
assert!(rx.try_recv().is_ok());
}
// The oldest digest made room for the newest, so its re-send is admitted again.
assert!(queue(&mut route, "candidate-0"));
assert!(rx.try_recv().is_ok());
// A recent one is still skipped.
let recent = format!("candidate-{}", ICE_DEDUP_WINDOW);
assert!(queue(&mut route, &recent));
assert!(rx.try_recv().is_err());
}
// The second way in that the repeat punch opens: a punch reaching a peer already in SYN_SENT
// is answered by that socket rather than reset, and the two ends come up on one connection.
// A punch that misses the crossing is reset outright here, loopback having no NAT to absorb
// it and no round trip to hide behind - so a single punch lands only by luck, and repeating
// is what makes it land at all. That is the premise of the repeat, asserted directly. A round
// that misses costs one loopback RST, so rounds are cheap and there are many.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_punch_that_meets_the_peers_syn_connects_both_ends() {
// The crossing needs both connects genuinely in flight at once. Loopback answers a SYN to
// a port nobody is listening on with an instant RST, so on one CPU the first connect runs
// to completion before the second is scheduled and no round can ever cross - a property of
// the box, which this test cannot tell apart from a broken punch.
if proxied() || std::thread::available_parallelism().map_or(true, |cpus| cpus.get() < 2) {
return;
}
for _ in 0..256 {
let (a, b) = free_loopback_pair().await;
// Held for the whole crossing, because production always has one here and the design
// rests on which of the two the kernel hands the connection to: the punch and the
// peer's SYN share a four-tuple exactly, the listener only matches the address, and
// the punch has to win that or every crossing would be swallowed as a plain accept.
let listener = new_listener(a, true).await.unwrap();
let to_b = tokio::spawn(socket_client::connect_tcp_local(b, Some(a), 3000));
let to_a = tokio::spawn(socket_client::connect_tcp_local(a, Some(b), 3000));
let (at_a, at_b) = tokio::join!(to_b, to_a);
let (Ok(Ok(mut at_a)), Ok(Ok(mut at_b))) = (at_a, at_b) else {
continue;
};
at_a.send_bytes(bytes::Bytes::from_static(b"punch"))
.await
.unwrap();
let got = at_b.next_timeout(3000).await.unwrap().unwrap();
assert_eq!(&got[..], b"punch", "both ends must share one connection");
assert!(
hbb_common::timeout(200, listener.accept()).await.is_err(),
"the crossing must reach the punch, not be accepted as an inbound connection"
);
return;
}
panic!("no punch met the peer's SYN in 256 rounds on a machine that can cross them");
}
// The punch binds the address the listener already holds, so it has to go through the same
// `connect_tcp_local` production uses - a punch built by hand here would still pass if
// `new_socket` ever stopped setting the reuse flags, while every real punch failed to bind.
// The peer's view of the source port is what proves the bind took: a fallback to an ephemeral
// one would connect just as happily.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_punch_binds_the_address_the_listener_holds() {
if proxied() {
return;
}
// `free_loopback_pair` hands back ports it no longer holds, so another process can take
// one in between; retry rather than fail for something the punch had no part in.
for _ in 0..8 {
let (local, peer_addr) = free_loopback_pair().await;
let (Ok(listener), Ok(peer)) = (
new_listener(local, true).await,
new_listener(peer_addr, true).await,
) else {
continue;
};
let punch = tokio::spawn(socket_client::connect_tcp_local(
peer_addr,
Some(local),
1500,
));
let (_peer_side, seen_as) = hbb_common::timeout(3000, peer.accept())
.await
.expect("the punch must reach the peer")
.unwrap();
assert_eq!(
seen_as.port(),
local.port(),
"the punch must leave from the address the listener holds, not an ephemeral one"
);
// Held, not asserted and dropped: the coexistence below is only exercised while this
// socket is still on the address, which is the state production spends its window in.
let _punched = punch.await.unwrap().expect("the punch must connect");
let dialed = tokio::spawn(tokio::net::TcpStream::connect(local));
let accepted = hbb_common::timeout(3000, listener.accept()).await;
assert!(
matches!(accepted, Ok(Ok(_))),
"the listener must still take connections while a punch shares its address: {accepted:?}"
);
assert!(dialed.await.unwrap().is_ok());
return;
}
panic!("could not hold two free loopback addresses in 8 tries");
}
// The schedule on its own, against a paused clock: the window is CONNECT_TIMEOUT long, and
// what these pin is where inside it the punches fall, which no socket could show.
#[tokio::test(start_paused = true)]
async fn the_punches_end_on_one_at_the_deadline() {
use super::{punch_until, PUNCH_GRACE, PUNCH_INTERVAL, PUNCH_MAX_INTERVAL};
use hbb_common::{anyhow::anyhow, config::CONNECT_TIMEOUT};
use std::time::Duration;
use tokio::time::Instant;
let peer: SocketAddr = "127.0.0.1:1".parse().unwrap();
let start = Instant::now();
let until = start + Duration::from_millis(CONNECT_TIMEOUT);
let mut punches = Vec::new();
// A gateway that answers with RST: every punch fails the moment it is made.
let met = punch_until::<(), _, _>(until, peer, |ms| {
punches.push((Instant::now(), ms));
async { Err(anyhow!("RST")) }
})
.await;
assert!(met.is_none());
assert_eq!(
Instant::now(),
until,
"must return the moment the window closes, not a backoff later"
);
// Tokio rounds every sleep up to the next millisecond.
let slack = Duration::from_millis(1);
assert!(punches[0].0 - start <= Duration::from_secs_f32(PUNCH_INTERVAL) + slack);
for pair in punches.windows(2) {
assert!(
pair[1].0 - pair[0].0 <= Duration::from_secs_f32(PUNCH_MAX_INTERVAL) + slack,
"no gap in the window may exceed the backoff ceiling: {pair:?}"
);
}
assert_eq!(
*punches.last().unwrap(),
(until, PUNCH_GRACE),
"the window must end on a punch, given the whole grace"
);
}
#[tokio::test(start_paused = true)]
async fn a_punch_in_flight_runs_the_grace_past_the_deadline_and_no_further() {
use super::{punch_until, PUNCH_GRACE};
use hbb_common::{anyhow::anyhow, config::CONNECT_TIMEOUT};
use std::time::Duration;
use tokio::time::Instant;
let peer: SocketAddr = "127.0.0.1:1".parse().unwrap();
let until = Instant::now() + Duration::from_millis(CONNECT_TIMEOUT);
let mut punches = 0;
// A gateway that drops the SYN in silence: the punch sits in SYN_SENT for all it is given.
let met = punch_until::<(), _, _>(until, peer, |ms| {
punches += 1;
async move {
tokio::time::sleep(Duration::from_millis(ms)).await;
Err(anyhow!("timed out"))
}
})
.await;
assert!(met.is_none());
assert_eq!(
punches, 1,
"a punch held in SYN_SENT is the only one the window needs"
);
assert_eq!(
Instant::now(),
until + Duration::from_millis(PUNCH_GRACE),
"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)
-148
View File
@@ -609,154 +609,6 @@ mod cpal_impl {
)?;
Ok((stream, errors))
}
#[cfg(test)]
mod tests {
use super::super::audio_capture_queue::{
new_pcm_handoff, start_capture_encoder, CaptureEncoderConfig,
};
use super::{
capture_packet_layout, convert_input_samples, CaptureFrameProcessor,
CaptureFrameProcessorConfig,
};
use crate::audio_resampler::allocation_tests::assert_no_allocations;
use crate::server::EmptyExtraFieldService;
use magnum_opus::Channels::{Mono, Stereo};
const INVALID_CAPTURE_RATE: u32 = 99;
const RATE_24_KHZ: u32 = 24_000;
const RATE_44_1_KHZ: u32 = 44_100;
const RATE_48_KHZ: u32 = 48_000;
const MONO_CHANNELS: u16 = 1;
const NEGATIVE_FULL_SCALE_LIMIT: f32 = -0.99;
const POSITIVE_FULL_SCALE_LIMIT: f32 = 0.99;
const STEREO_CHANNELS: u16 = 2;
const SURROUND_CHANNELS: u16 = 6;
const ZERO_CHANNELS: u16 = 0;
#[test]
fn capture_sample_conversion_uses_cpal_traits() {
let input = [i16::MIN, 0, i16::MAX];
let output: Vec<_> = convert_input_samples(&input).collect();
assert_eq!(output.len(), input.len());
assert!(output[0] <= NEGATIVE_FULL_SCALE_LIMIT);
assert_eq!(output[1], 0.0);
assert!(output[2] >= POSITIVE_FULL_SCALE_LIMIT);
}
#[test]
fn capture_packet_layout_validates_rate_and_channels() {
let expected_frames = RATE_48_KHZ as usize / super::AUDIO_PACKETS_PER_SECOND;
assert_eq!(
capture_packet_layout(RATE_48_KHZ, STEREO_CHANNELS).unwrap(),
(expected_frames, expected_frames * STEREO_CHANNELS as usize)
);
assert!(capture_packet_layout(INVALID_CAPTURE_RATE, MONO_CHANNELS).is_err());
assert!(capture_packet_layout(RATE_48_KHZ, ZERO_CHANNELS).is_err());
}
#[test]
fn capture_callback_pipeline_does_not_allocate_after_warmup() {
for (input_rate, output_rate, device_channel, encode_channel) in [
(RATE_48_KHZ, RATE_48_KHZ, MONO_CHANNELS, MONO_CHANNELS),
(RATE_48_KHZ, RATE_48_KHZ, STEREO_CHANNELS, STEREO_CHANNELS),
(RATE_44_1_KHZ, RATE_24_KHZ, STEREO_CHANNELS, STEREO_CHANNELS),
(RATE_48_KHZ, RATE_48_KHZ, SURROUND_CHANNELS, STEREO_CHANNELS),
] {
assert_capture_processor_does_not_allocate(CaptureFrameProcessorConfig {
input_rate,
output_rate,
device_channel,
encode_channel,
});
}
}
#[test]
fn capture_pcm_handoff_reuses_buffers_and_accounts_for_loss() {
const QUEUE_CAPACITY: usize = 2;
const PACKET_SAMPLES: usize = 4;
const FIRST: [f32; PACKET_SAMPLES] = [1.0; PACKET_SAMPLES];
const SECOND: [f32; PACKET_SAMPLES] = [2.0; PACKET_SAMPLES];
const THIRD: [f32; PACKET_SAMPLES] = [3.0; PACKET_SAMPLES];
const OVERSIZED_SAMPLES: usize = PACKET_SAMPLES + 1;
const OVERSIZED: [f32; OVERSIZED_SAMPLES] = [1.0; OVERSIZED_SAMPLES];
let (mut sender, receiver) = new_pcm_handoff(QUEUE_CAPACITY, PACKET_SAMPLES).unwrap();
sender.set_wake_thread(std::thread::current()).unwrap();
assert_no_allocations(|| {
sender.submit(&FIRST);
sender.submit(&SECOND);
sender.submit(&THIRD);
});
let loss = receiver.take_loss();
assert_eq!(loss.dropped, 1);
assert_eq!(loss.oversized, 0);
assert_eq!(loss.recycle_failures, 0);
let second = receiver.pop().unwrap();
let third = receiver.pop().unwrap();
assert_eq!(second, SECOND);
assert_eq!(third, THIRD);
receiver.recycle(second);
receiver.recycle(third);
assert!(receiver.is_empty());
assert_no_allocations(|| sender.submit(&OVERSIZED));
let loss = receiver.take_loss();
assert_eq!(loss.dropped, 0);
assert_eq!(loss.oversized, 1);
assert_eq!(loss.recycle_failures, 0);
assert!(receiver.is_empty());
}
#[test]
fn capture_pcm_handoff_rejects_invalid_layouts() {
assert!(new_pcm_handoff(0, 1).is_err());
assert!(new_pcm_handoff(1, 0).is_err());
}
fn assert_capture_processor_does_not_allocate(config: CaptureFrameProcessorConfig) {
const INPUT_LEVEL: f32 = 0.25;
const TEST_SERVICE_NAME: &str = "audio-allocation-test";
let service = EmptyExtraFieldService::new(TEST_SERVICE_NAME.to_owned(), true).sp;
let encode_channel = if config.encode_channel == MONO_CHANNELS {
Mono
} else {
Stereo
};
let encoder_config = CaptureEncoderConfig {
sample_rate: config.output_rate,
encode_channel,
max_packet_samples: config.output_rate as usize / super::AUDIO_PACKETS_PER_SECOND
* config.device_channel.max(config.encode_channel) as usize,
};
let (sender, worker) = start_capture_encoder(encoder_config, service).unwrap();
let mut processor = CaptureFrameProcessor::new(config, sender).unwrap();
let errors = super::CaptureErrorHandler::default();
let input = vec![
INPUT_LEVEL;
config.input_rate as usize / super::AUDIO_PACKETS_PER_SECOND
* config.device_channel as usize
];
let mut frame_buffer =
super::audio_capture::CaptureFrameBuffer::new(input.len()).unwrap();
frame_buffer.process(convert_input_samples(&input), |frame| {
errors.process_frame(|| processor.process(frame));
});
assert_no_allocations(|| {
frame_buffer.process(convert_input_samples(&input), |frame| {
errors.process_frame(|| processor.process(frame));
});
});
assert!(!errors.needs_restart());
drop(processor);
drop(worker);
}
}
}
fn create_format_msg(sample_rate: u32, channels: u16) -> Message {
@@ -1,6 +1,5 @@
use super::super::{new_pcm_handoff, CAPTURE_PCM_QUEUE_PACKETS};
use super::*;
use crate::audio_resampler::allocation_tests::assert_no_allocations;
use magnum_opus::{Application::LowDelay, Decoder, Encoder};
const SAMPLE_RATE: u32 = 48_000;
@@ -10,8 +9,6 @@ const PAUSE_PACKET: usize = 50;
const DROPPED_PACKETS: usize = 6;
const SIGNAL_FREQUENCY: f64 = 97.0;
const SIGNAL_AMPLITUDE: f64 = 0.5;
const ACTIVE_LEVEL: f32 = 0.8;
const SAMPLE_TOLERANCE: f32 = 0.000001;
const MAX_ENCODE_BYTES_PER_SAMPLE: usize = 6;
fn signal_packet(index: usize, channels: usize) -> Vec<f32> {
@@ -85,102 +82,3 @@ fn capture_overflow_is_smoothed_before_encoding() {
);
}
}
#[test]
fn rejected_packets_mark_the_gap_after_already_queued_audio() {
const CAPACITY: usize = 3;
let samples = SAMPLE_RATE as usize / PACKETS_PER_SECOND;
let (mut sender, receiver) = new_pcm_handoff(CAPACITY, samples).unwrap();
let mut state = CaptureEncoderState::new(SAMPLE_RATE, Channels::Mono);
let active = vec![ACTIVE_LEVEL; samples];
let opposite = vec![-ACTIVE_LEVEL; samples];
sender.submit(&active);
let packet = state.next_packet(&receiver).unwrap();
assert_eq!(packet, active);
receiver.recycle(packet);
sender.submit(&active);
sender.submit(&active);
sender.submit(&vec![ACTIVE_LEVEL; samples + 1]);
sender.submit(&opposite);
assert_eq!(receiver.take_loss().oversized, 1);
assert_no_allocations(|| {
for _ in 0..CAPACITY - 1 {
let packet = state.next_packet(&receiver).unwrap();
assert_eq!(packet, active);
receiver.recycle(packet);
}
let packet = state.next_packet(&receiver).unwrap();
let expected = ACTIVE_LEVEL * (1.0 - 2.0 / state.fade_frames as f32);
assert!((packet[0] - expected).abs() < SAMPLE_TOLERANCE);
assert_eq!(&packet[samples / 2..], &opposite[samples / 2..]);
receiver.recycle(packet);
});
}
#[test]
fn gaps_and_sequence_wrap_preserve_channel_history() {
const RATE_8_KHZ: u32 = 8_000;
for (rate, channels) in [
(RATE_8_KHZ, Channels::Mono),
(RATE_8_KHZ, Channels::Stereo),
(SAMPLE_RATE, Channels::Mono),
(SAMPLE_RATE, Channels::Stereo),
] {
let channels_count = channels as usize;
let mut state = CaptureEncoderState::new(rate, channels);
let samples = rate as usize / PACKETS_PER_SECOND * channels_count;
let active: Vec<_> = [ACTIVE_LEVEL, -ACTIVE_LEVEL][..channels_count]
.iter()
.copied()
.cycle()
.take(samples)
.collect();
let mut first = active.clone();
state.smooth_packet(0, &mut first);
assert_eq!(first, active);
let mut opposite: Vec<_> = active.iter().map(|sample| -sample).collect();
let mut resumed = active.clone();
assert_no_allocations(|| {
state.smooth_packet(2, &mut opposite);
state.smooth_packet(4, &mut resumed);
});
for channel in 0..channels_count {
let expected = active[channel] * (1.0 - 2.0 / state.fade_frames as f32);
assert!((opposite[channel] - expected).abs() < SAMPLE_TOLERANCE);
let previous = opposite[samples - channels_count + channel];
let expected = previous + (active[channel] - previous) / state.fade_frames as f32;
assert!((resumed[channel] - expected).abs() < SAMPLE_TOLERANCE);
}
assert_eq!(&resumed[samples / 2..], &active[samples / 2..]);
state.expected_sequence = usize::MAX;
let mut last = active.clone();
let mut wrapped: Vec<_> = active.iter().map(|sample| -sample).collect();
let expected = wrapped.clone();
assert_no_allocations(|| {
state.smooth_packet(usize::MAX, &mut last);
state.smooth_packet(0, &mut wrapped);
});
assert_eq!(last, active);
assert_eq!(wrapped, expected);
}
}
#[test]
fn capture_loss_is_reported_while_packets_remain_queued() {
const CAPACITY: usize = 2;
let samples = SAMPLE_RATE as usize / PACKETS_PER_SECOND;
let (mut sender, receiver) = new_pcm_handoff(CAPACITY, samples).unwrap();
let mut state = CaptureEncoderState::new(SAMPLE_RATE, Channels::Mono);
let before_report = Instant::now() - CAPTURE_STATS_LOG_INTERVAL;
state.reporter.last_report = before_report;
let packet = vec![ACTIVE_LEVEL; samples];
for _ in 0..=CAPACITY {
sender.submit(&packet);
}
let packet = state.next_packet(&receiver).unwrap();
assert!(!receiver.is_empty());
assert!(state.reporter.last_report > before_report);
assert!(state.reporter.pending.is_empty());
assert!(receiver.take_loss().is_empty());
receiver.recycle(packet);
}
@@ -67,15 +67,6 @@ mod tests {
assert!(errors.needs_restart());
}
#[test]
fn late_error_from_an_old_stream_does_not_restart_its_replacement() {
let old_callback = CaptureErrorHandler::default();
let replacement = CaptureErrorHandler::default();
old_callback.handle(system_interruption());
assert!(old_callback.needs_restart());
assert!(!replacement.needs_restart());
}
#[test]
fn other_backend_errors_do_not_request_recreation() {
let errors = CaptureErrorHandler::default();
@@ -86,44 +77,4 @@ mod tests {
});
assert!(!errors.needs_restart());
}
#[test]
fn processing_failure_skips_remaining_frames_until_stream_replacement() {
const FRAME_SAMPLES: usize = 2;
const FRAMES_PER_CALLBACK: usize = 3;
const CALLBACK_COUNT: usize = 2;
const FAILURE_CALL: usize = 2;
let errors = CaptureErrorHandler::default();
let callback_errors = errors.clone();
let mut framer =
super::super::audio_capture::CaptureFrameBuffer::new(FRAME_SAMPLES).unwrap();
let input = [0.0; FRAME_SAMPLES * FRAMES_PER_CALLBACK];
let mut processed = 0;
let mut failures = 0;
// Inject an error to test recovery; this is not a valid-input backend failure reproduction.
for _ in 0..CALLBACK_COUNT {
framer.process(input.iter().copied(), |_| {
callback_errors.process_frame(|| {
processed += 1;
if processed >= FAILURE_CALL {
failures += 1;
hbb_common::anyhow::bail!("Injected capture processing failure");
}
Ok(())
});
});
}
assert_eq!(processed, FAILURE_CALL);
assert_eq!(failures, 1);
assert!(errors.needs_restart());
let replacement = CaptureErrorHandler::default();
replacement.process_frame(|| {
processed += 1;
Ok(())
});
assert_eq!(processed, FAILURE_CALL + 1);
assert!(!replacement.needs_restart());
}
}
@@ -226,11 +226,6 @@ impl CapturePcmSender {
}
impl CapturePcmReceiver {
#[cfg(test)]
pub(super) fn pop(&self) -> Option<Vec<f32>> {
self.pop_packet().map(|(_, buffer)| buffer)
}
fn pop_packet(&self) -> Option<(usize, Vec<f32>)> {
self.handoff.buffers.lock().unwrap().ready.pop_front()
}
@@ -4,49 +4,25 @@ use std::{sync::mpsc, time::Duration};
const PACKET_SAMPLES: usize = 4;
const TEST_TIMEOUT: Duration = Duration::from_secs(2);
const CONCURRENT_PACKETS: usize = 10_000;
#[derive(Clone, Copy)]
enum PausePoint {
Recycle,
Consume,
}
struct WorkerPause {
entered: mpsc::Sender<()>,
resume: mpsc::Receiver<()>,
}
fn paused_worker(
receiver: CapturePcmReceiver,
point: PausePoint,
pause: WorkerPause,
) -> CapturePcmReceiver {
let recycled = match point {
PausePoint::Recycle => Some(receiver.pop_packet().unwrap().1),
PausePoint::Consume => None,
};
fn paused_worker(receiver: CapturePcmReceiver, pause: WorkerPause) -> CapturePcmReceiver {
let consumed = {
let mut buffers = receiver.handoff.buffers.lock().unwrap();
let consumed = match recycled {
Some(mut packet) => {
packet.clear();
buffers.available.push(packet);
None
}
None => Some(buffers.ready.pop_front().unwrap().1),
};
let consumed = buffers.ready.pop_front().unwrap().1;
pause.entered.send(()).unwrap();
pause.resume.recv().unwrap();
consumed
};
if let Some(packet) = consumed {
receiver.recycle(packet);
}
receiver.recycle(consumed);
receiver
}
fn assert_callback_progress(point: PausePoint) {
fn assert_callback_progress() {
let (mut sender, receiver) =
new_pcm_handoff(CAPTURE_PCM_QUEUE_PACKETS, PACKET_SAMPLES).unwrap();
for sequence in 0..CAPTURE_PCM_QUEUE_PACKETS {
@@ -58,7 +34,7 @@ fn assert_callback_progress(point: PausePoint) {
entered: entered_tx,
resume: resume_rx,
};
let worker = std::thread::spawn(move || paused_worker(receiver, point, pause));
let worker = std::thread::spawn(move || paused_worker(receiver, pause));
sender.set_wake_thread(worker.thread().clone()).unwrap();
entered_rx.recv_timeout(TEST_TIMEOUT).unwrap();
let (completed_tx, completed_rx) = mpsc::channel();
@@ -108,62 +84,7 @@ fn assert_pool_restored(receiver: &CapturePcmReceiver, capacity: usize) {
.all(|buffer| buffer.capacity() >= PACKET_SAMPLES));
}
#[test]
fn callback_finishes_while_worker_recycles_a_buffer() {
assert_callback_progress(PausePoint::Recycle);
}
#[test]
fn callback_finishes_while_worker_releases_a_ready_packet() {
assert_callback_progress(PausePoint::Consume);
}
fn consume_concurrently(
receiver: CapturePcmReceiver,
finished: Arc<AtomicBool>,
first_sequence: usize,
) -> (CapturePcmReceiver, usize) {
let mut received = 0;
let mut next_ordinal = 0;
loop {
if let Some((sequence, packet)) = receiver.pop_packet() {
let ordinal = sequence.wrapping_sub(first_sequence);
assert!(ordinal >= next_ordinal && ordinal < CONCURRENT_PACKETS);
assert_eq!(packet, [ordinal as f32; PACKET_SAMPLES]);
next_ordinal = ordinal + 1;
received += 1;
receiver.recycle(packet);
} else if finished.load(Ordering::Acquire) && receiver.is_empty() {
return (receiver, received);
} else {
std::thread::yield_now();
}
}
}
#[test]
fn concurrent_handoff_preserves_order_buffers_and_loss_counts_across_sequence_wrap() {
const FIRST_SEQUENCE: usize = usize::MAX - CONCURRENT_PACKETS / 2;
for capacity in [1, 2, CAPTURE_PCM_QUEUE_PACKETS] {
let (mut sender, receiver) = new_pcm_handoff(capacity, PACKET_SAMPLES).unwrap();
sender.sequence = FIRST_SEQUENCE;
let finished = Arc::new(AtomicBool::new(false));
let worker_finished = finished.clone();
let worker = std::thread::spawn(move || {
consume_concurrently(receiver, worker_finished, FIRST_SEQUENCE)
});
assert_no_allocations(|| {
for ordinal in 0..CONCURRENT_PACKETS {
sender.submit(&[ordinal as f32; PACKET_SAMPLES]);
}
});
finished.store(true, Ordering::Release);
let (receiver, received) = worker.join().unwrap();
let stats = receiver.take_stats();
assert_eq!(received + stats.loss.dropped, CONCURRENT_PACKETS);
assert_eq!(stats.loss.oversized, 0);
assert_eq!(stats.loss.recycle_failures, 0);
assert!(stats.max_queued_packets <= capacity);
assert_pool_restored(&receiver, capacity);
}
assert_callback_progress();
}
-25
View File
@@ -349,31 +349,6 @@ fn run(sp: EmptyExtraFieldService) -> ResultType<()> {
mod tests {
use super::{decode_utf8_prefix, WAYLAND_CLIPBOARD_SKIP_CHECK_MAX_UTF8_BYTES};
#[test]
fn decode_utf8_prefix_returns_text_for_valid_utf8() {
let text = "hello-مرحبا";
assert_eq!(decode_utf8_prefix(text.as_bytes()), Some(text.to_owned()));
}
#[test]
fn decode_utf8_prefix_returns_none_for_invalid_utf8_sequence() {
let bytes = b"ab\xffcd";
assert_eq!(decode_utf8_prefix(bytes), None);
}
#[test]
fn decode_utf8_prefix_trims_incomplete_utf8_suffix() {
let bytes = vec![b'a', 0xE4, 0xB8];
assert_eq!(decode_utf8_prefix(&bytes), Some("a".to_owned()));
}
#[test]
fn decode_utf8_prefix_applies_max_bytes_limit() {
let bytes = vec![b'a'; WAYLAND_CLIPBOARD_SKIP_CHECK_MAX_UTF8_BYTES + 8];
let result = decode_utf8_prefix(&bytes).expect("expected decoded prefix");
assert_eq!(result.len(), WAYLAND_CLIPBOARD_SKIP_CHECK_MAX_UTF8_BYTES);
}
#[test]
fn decode_utf8_prefix_keeps_utf8_boundary_when_limited() {
let mut bytes = vec![b'a'; WAYLAND_CLIPBOARD_SKIP_CHECK_MAX_UTF8_BYTES - 1];
-180
View File
@@ -7200,64 +7200,6 @@ mod test {
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) {
@@ -7473,54 +7415,6 @@ mod test {
assert!(wildcard_match(" 123456789 ", "123456789"));
}
#[test]
fn test_decay_stale_failures() {
let entry = |minute: i32| (minute, 1, 40);
let keys = ["ip".to_string(), "p64".to_string(), "absent".to_string()];
let mut m: HashMap<String, (i32, i32, i32)> = HashMap::new();
m.insert("ip".to_string(), entry(100));
m.insert("p64".to_string(), entry(160));
m.insert("untouched".to_string(), entry(100));
// Exactly at the window: forgotten. Still inside it: kept.
decay_stale_failures(&mut m, &keys, 160, 60);
assert!(!m.contains_key("ip"));
assert!(m.contains_key("p64"));
// Keys that were not passed in are never visited, absent ones are a no-op.
assert!(m.contains_key("untouched"));
// One minute short of the window keeps the entry.
decay_stale_failures(&mut m, &keys, 219, 60);
assert!(m.contains_key("p64"));
decay_stale_failures(&mut m, &keys, 220, 60);
assert!(!m.contains_key("p64"));
// A clock that jumped backwards must not drop anything.
m.insert("ip".to_string(), entry(500));
decay_stale_failures(&mut m, &keys, 0, 60);
assert!(m.contains_key("ip"));
}
#[test]
fn test_clear_failures_drops_shared_prefixes() {
// On IPv6 a whitelisted peer usually has no entry of its own, while the shared
// prefixes that block it do. Clearing must not depend on the per-address entry.
let mut m: HashMap<String, (i32, i32, i32)> = HashMap::new();
m.insert("p64".to_string(), (100, 1, 55));
m.insert("p56".to_string(), (100, 1, 75));
m.insert("p48".to_string(), (100, 1, 95));
m.insert("someone-else".to_string(), (100, 1, 95));
let keys = ["ip", "p64", "p56", "p48"].map(|k| k.to_string());
clear_failures(&mut m, &keys);
for key in ["p64", "p56", "p48"] {
assert!(!m.contains_key(key), "{key} should have been cleared");
}
// Keys belonging to other peers are left alone.
assert!(m.contains_key("someone-else"));
}
#[test]
fn test_id_whitelist_allows() {
let list = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
@@ -7613,13 +7507,6 @@ mod test {
assert_eq!(pos.y, 510);
}
#[test]
fn ipv6() {
assert!(Ipv6Addr::from_str("::1").is_ok());
assert!(Ipv6Addr::from_str("127.0.0.1").is_err());
assert!(Ipv6Addr::from_str("0").is_err());
}
fn msg(set: impl FnOnce(&mut Message)) -> Message {
let mut msg = Message::new();
set(&mut msg);
@@ -7642,10 +7529,6 @@ mod test {
})
}
fn set_supported_decoding(option: &mut OptionMessage) {
option.supported_decoding = hbb_common::protobuf::MessageField::some(Default::default());
}
fn assert_scopes(
conn_type: AuthConnType,
cases: impl IntoIterator<Item = (Message, Option<&'static str>)>,
@@ -7909,69 +7792,6 @@ mod test {
assert_eq!(violation, Some("login.option"));
}
#[test]
fn session_scope_limited_render_noop_options_reject_mixed_fields() {
for conn_type in [
AuthConnType::FileTransfer,
AuthConnType::Terminal,
AuthConnType::PortForward,
] {
let supported_decoding_only = option_msg(set_supported_decoding);
assert_eq!(
Connection::authorized_message_scope_violation(conn_type, &supported_decoding_only),
None
);
let mixed_option = option_msg(|o| {
set_supported_decoding(o);
o.disable_audio = BoolOption::Yes.into();
});
assert_eq!(
Connection::authorized_message_scope_violation(conn_type, &mixed_option),
Some("misc.option")
);
}
}
#[test]
fn session_scope_view_camera_options_keep_only_camera_fields() {
let mut option = OptionMessage::new();
option.image_quality = ImageQuality::Balanced.into();
option.custom_image_quality = 80;
option.custom_fps = 24;
set_supported_decoding(&mut option);
option.disable_audio = BoolOption::Yes.into();
option.block_input = BoolOption::Yes.into();
option.disable_clipboard = BoolOption::Yes.into();
option.enable_file_transfer = BoolOption::Yes.into();
option.terminal_persistent = BoolOption::Yes.into();
let (scoped, violation) =
Connection::scoped_login_option(AuthConnType::ViewCamera, &option);
let scoped = scoped.unwrap();
assert_eq!(violation, Some("login.option"));
assert_eq!(
scoped.image_quality.enum_value(),
Ok(ImageQuality::Balanced)
);
assert_eq!(scoped.custom_image_quality, 80);
assert_eq!(scoped.custom_fps, 24);
assert!(scoped.supported_decoding.is_some());
assert_eq!(scoped.disable_audio.enum_value(), Ok(BoolOption::Yes));
assert_eq!(scoped.block_input.enum_value(), Ok(BoolOption::NotSet));
assert_eq!(
scoped.disable_clipboard.enum_value(),
Ok(BoolOption::NotSet)
);
assert_eq!(
scoped.enable_file_transfer.enum_value(),
Ok(BoolOption::NotSet)
);
assert_eq!(
scoped.terminal_persistent.enum_value(),
Ok(BoolOption::NotSet)
);
}
#[test]
fn only_a_newer_remote_control_of_the_same_session_keeps_the_screen_unlocked() {
let replaced_by = super::raii::AuthedConnID::is_newer_session_remote;
+1 -178
View File
@@ -1025,68 +1025,12 @@ pub fn try_get_displays_(add_amyuni_headless: bool) -> ResultType<Vec<Display>>
Ok(displays)
}
#[cfg(test)]
mod tests {
use super::normalize_primary_display_idx;
#[test]
fn normalize_primary_display_idx_bounds() {
assert_eq!(normalize_primary_display_idx(0, 0), 0);
assert_eq!(normalize_primary_display_idx(0, 2), 0);
assert_eq!(normalize_primary_display_idx(1, 2), 1);
assert_eq!(normalize_primary_display_idx(2, 2), 0);
}
}
#[cfg(all(test, target_os = "linux", feature = "drm"))]
mod input_map_tests {
use super::{
input_map_epoch, input_map_gen, input_map_label, note_input_map_adopted,
note_input_map_ready, note_input_map_unknown, run_uinput_apply, set_wayland_uinput_rect,
wayland_uinput_rect,
};
// The input-map statics are process-wide and libtest runs tests concurrently.
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn an_apply_leaves_no_range_ready_until_it_is_acknowledged() {
let _serial = SERIAL.lock().unwrap_or_else(|p| p.into_inner());
set_wayland_uinput_rect((0, 1920, 0, 1080));
note_input_map_adopted(41);
let before = input_map_epoch();
note_input_map_unknown();
assert_eq!(input_map_gen(), u64::MAX, "no generation is ready");
assert_eq!(input_map_epoch(), before + 1, "samples from before do not count");
assert_eq!(wayland_uinput_rect(), None, "the poll applies again");
}
#[test]
fn the_raw_drm_union_labels_a_range_with_no_generation() {
assert_eq!(input_map_label(5, true), 5);
assert_eq!(input_map_label(5, false), u64::MAX);
}
#[test]
fn an_acknowledged_range_is_an_adoption_and_a_fitting_one_is_not() {
let _serial = SERIAL.lock().unwrap_or_else(|p| p.into_inner());
let before = input_map_epoch();
note_input_map_adopted(41);
assert_eq!((input_map_gen(), input_map_epoch()), (41, before + 1));
note_input_map_ready(42);
assert_eq!(
(input_map_gen(), input_map_epoch()),
(42, before + 1),
"a range that already fits is not an adoption"
);
}
// The apply thread is process-wide, and one test kills it on purpose.
static WORKER: std::sync::Mutex<()> = std::sync::Mutex::new(());
use super::run_uinput_apply;
#[test]
fn uinput_applies_run_one_at_a_time_in_the_order_asked() {
let _worker = WORKER.lock().unwrap_or_else(|p| p.into_inner());
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let answers: Vec<_> = (0..4)
.map(|i| {
@@ -1107,35 +1051,6 @@ mod input_map_tests {
let want: Vec<_> = (0..4).flat_map(|i| [("start", i), ("end", i)]).collect();
assert_eq!(*log.lock().unwrap(), want, "one job at a time, in order");
}
#[test]
fn a_uinput_apply_job_can_bound_its_ipc_with_a_timeout() {
let _worker = WORKER.lock().unwrap_or_else(|p| p.into_inner());
let timed_out = run_uinput_apply(|rt| {
rt.block_on(async {
hbb_common::timeout(10, std::future::pending::<()>())
.await
.is_err()
})
});
assert!(timed_out.blocking_recv().unwrap());
}
#[test]
fn a_uinput_apply_thread_that_died_is_started_again() {
let _worker = WORKER.lock().unwrap_or_else(|p| p.into_inner());
let died = run_uinput_apply(|_| panic!("the apply thread dies here"));
assert!(died.blocking_recv().is_err(), "the job that killed it has no answer");
// A job asked while the dying thread still unwinds is lost with it; a later one runs.
let answered = (0..50).find_map(|_| {
let answer = run_uinput_apply(|_| 7).blocking_recv().ok();
if answer.is_none() {
std::thread::sleep(std::time::Duration::from_millis(10));
}
answer
});
assert_eq!(answered, Some(7));
}
}
#[cfg(all(test, target_os = "linux"))]
@@ -1168,18 +1083,6 @@ mod wayland_layout_tests {
assert!(l.edge(&rotated, false, 0));
}
// The same, with no poll ever having run: the outgoing baseline is the only record of what
// the first capturer was built against.
#[test]
fn a_rotation_between_two_inits_before_the_first_poll_is_still_an_edge() {
let upright = layout(1920, 1080, 0);
let rotated = layout(1080, 1920, 1);
let mut l = WaylandLayout::default();
l.reset_baseline(upright.clone());
l.reset_baseline(rotated.clone());
assert!(l.edge(&rotated, false, 0));
}
// Control: without it the asserts above would pass on a detector that always fires.
#[test]
fn repeated_baseline_resets_without_a_rotation_are_not_an_edge() {
@@ -1220,86 +1123,6 @@ mod wayland_layout_tests {
assert!(!l3.edge(&upright, false, 0));
}
// A capturer built while the poll already has a memory must not overwrite it.
#[test]
fn a_later_capturer_does_not_overwrite_the_polls_memory() {
let upright = layout(1920, 1080, 0);
let rotated = layout(1080, 1920, 1);
let mut l = WaylandLayout::default();
l.observe(&upright);
l.note_capturer(&rotated, 0);
assert!(l.edge(&rotated, false, 0), "the poll's memory still says upright");
}
// The constructor's snapshot read and its `note_capturer` are two steps, and the poll can
// land between them. After a failed init (empty baseline) the constructor takes A and
// publishes it; the output rotates; the poll reads B live, finds nothing recorded and the
// snapshot present, so no edge, and observes B. The late `note_capturer(A)` then met a
// non-empty memory and was dropped: the capturer showed A while the detector held B, and B
// against B never bumped the generation.
#[test]
fn a_capturer_record_that_lost_the_race_with_the_first_poll_is_still_an_edge() {
let upright = layout(1920, 1080, 0);
let rotated = layout(1080, 1920, 1);
let mut l = WaylandLayout::default();
l.reset_baseline(Vec::new());
assert!(!l.edge(&rotated, false, 0), "nothing recorded and the snapshot is present");
l.observe(&rotated);
l.note_capturer(&upright, 0);
assert!(l.edge(&rotated, false, 0), "the capturer is built on upright, live is rotated");
// The promotion consumes it: the next poll sees the same layout and stays quiet.
l.observe(&rotated);
l.reset_baseline(rotated.clone());
assert!(!l.edge(&rotated, false, 0));
// The same with a session init between the late record and the poll.
let mut l2 = WaylandLayout::default();
l2.reset_baseline(Vec::new());
l2.observe(&rotated);
l2.note_capturer(&upright, 0);
l2.reset_baseline(rotated.clone());
assert!(l2.edge(&rotated, false, 0));
// Control: a late record that agrees with the poll's memory is not an edge.
let mut l3 = WaylandLayout::default();
l3.reset_baseline(Vec::new());
l3.observe(&upright);
l3.note_capturer(&upright, 0);
assert!(!l3.edge(&upright, false, 0));
}
// The late record can also land after the poll consumed the edge but before the bump that
// edge promotes, or after the bump with a snapshot taken before it. That capturer is stale
// by generation and rebuilds on its own, so its record must not buy a second promotion
// that tears the freshly rebuilt capturers down again.
#[test]
fn a_late_record_from_a_generation_already_promoted_is_not_a_second_edge() {
let upright = layout(1920, 1080, 0);
let rotated = layout(1080, 1920, 1);
let mut l = WaylandLayout::default();
l.reset_baseline(upright.clone());
l.observe(&upright);
// The output rotates, the poll consumes the edge, the capturer built on upright at
// generation 7 records late, and the poll promotes to 8.
assert!(l.edge(&rotated, false, 7));
l.observe(&rotated);
l.note_capturer(&upright, 7);
l.reset_baseline(rotated.clone());
assert!(!l.edge(&rotated, false, 8), "the capturer built at 7 rebuilds on its own");
// Control: a disagreeing record AT the promoted generation is a real edge.
l.observe(&rotated);
l.note_capturer(&upright, 8);
assert!(l.edge(&rotated, false, 8));
// A stale record landing after a fresh one must not hide the fresh one.
l.observe(&rotated);
l.note_capturer(&upright, 8);
l.note_capturer(&upright, 7);
assert!(l.edge(&rotated, false, 8));
}
// A promotion consumes the edge: the next poll sees the same layout and must stay quiet.
#[test]
fn a_promoted_layout_is_not_an_edge_again() {
File diff suppressed because it is too large Load Diff
-39
View File
@@ -2826,12 +2826,8 @@ mod cursor_shape_tests {
mod peer_abs_sample_tests {
use super::*;
// The two writers share one static and libtest runs tests concurrently.
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn a_move_without_an_absolute_sample_forgets_the_last_one() {
let _serial = SERIAL.lock().unwrap_or_else(|p| p.into_inner());
note_peer_absolute_move(20, 30);
let s = last_peer_abs_sample().expect("an absolute move leaves a sample");
assert_eq!(s.pos, (20, 30));
@@ -2839,39 +2835,4 @@ mod peer_abs_sample_tests {
note_pointer_moved_without_absolute_sample();
assert_eq!(last_peer_abs_sample(), None);
}
#[test]
fn every_absolute_move_is_a_new_sample() {
let _serial = SERIAL.lock().unwrap_or_else(|p| p.into_inner());
note_peer_absolute_move(1, 1);
let a = last_peer_abs_sample().unwrap().seq;
note_peer_absolute_move(1, 1);
let b = last_peer_abs_sample().unwrap().seq;
assert!(b > a, "same point, distinct sample");
note_pointer_moved_without_absolute_sample();
}
#[test]
fn a_move_of_this_process_forgets_the_sample_of_a_peer_move_it_waited_for() {
let _serial = SERIAL.lock().unwrap_or_else(|p| p.into_inner());
let mouse = std::sync::Arc::new(std::sync::Mutex::new(()));
note_peer_absolute_move(5, 5);
// The peer holds the mouse lock when the move of this process starts.
let peer = mouse.lock().unwrap();
let (asked, asking) = std::sync::mpsc::channel();
let m = mouse.clone();
let mover = std::thread::spawn(move || {
let lock = || {
asked.send(()).unwrap();
m.lock().unwrap()
};
forget_sample_and_move(lock, |_| {})
});
asking.recv().unwrap();
assert!(last_peer_abs_sample().is_some(), "nothing forgotten before the lock");
note_peer_absolute_move(50, 50);
drop(peer);
mover.join().unwrap();
assert_eq!(last_peer_abs_sample(), None, "the move of this process came last");
}
}
-39
View File
@@ -171,8 +171,6 @@ pub(crate) fn try_acquire_os_credential_login_gate() -> Result<OwnedMutexGuard<(
mod tests {
use super::*;
static TEST_MUTEX: Mutex<()> = Mutex::new(());
fn clear_os_credential_failure_state(scope: FailureScope) {
if let Some(state_mutex) = state_for_os_credential_scope(scope) {
*state_mutex.lock().unwrap() = OsCredentialFailureState::default();
@@ -181,7 +179,6 @@ mod tests {
#[test]
fn os_credential_policy_prioritizes_backoff() {
let _guard = TEST_MUTEX.lock().unwrap();
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
let now_ms = get_time();
for _ in 0..3 {
@@ -192,40 +189,4 @@ mod tests {
assert!(decision.login_error.is_some());
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
}
#[test]
fn os_credential_policy_idle_window_resets_total_counter() {
let _guard = TEST_MUTEX.lock().unwrap();
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
for _ in 0..13 {
record_os_credential_failure(FailureScope::TerminalOsLogin);
}
let blocked = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, get_time());
assert!(!blocked.allowed);
let after_failures_ms = get_time();
let after_idle_ms = after_failures_ms + OS_CREDENTIAL_LOGIN_TOTAL_IDLE_RESET_MS + 1_000;
let allowed = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, after_idle_ms);
assert!(allowed.allowed);
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
}
#[test]
fn os_credential_policy_audits_every_backoff_block() {
let _guard = TEST_MUTEX.lock().unwrap();
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
for _ in 0..3 {
record_os_credential_failure(FailureScope::TerminalOsLogin);
}
let now_ms = get_time();
let first = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms);
let second = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms + 1_000);
assert!(!first.allowed);
assert!(!second.allowed);
assert!(first.audit.is_some());
assert!(second.audit.is_some());
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
}
}
+1 -66
View File
@@ -169,11 +169,6 @@ impl PortForwardMux {
self.channels.len()
}
#[cfg(test)]
pub fn recv_window_remaining(&self, id: i32) -> Option<u32> {
self.channels.get(&id).map(|e| e.window.lock().unwrap().remaining())
}
/// Every task ends and drops its target socket: the queue's senders go for
/// a task on the queue, `teardown` reaches one parked on the socket.
pub fn close_all(&mut self) {
@@ -260,7 +255,7 @@ fn unreachable_message(addr: &str, is_rdp: bool) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::port_forward_mux::{CHANNEL_WINDOW, INITIAL_WINDOW, MAX_CHANNELS, MIN_FRAME_CHARGE};
use crate::port_forward_mux::{CHANNEL_WINDOW, MAX_CHANNELS};
use hbb_common::{
tokio::{
self,
@@ -379,18 +374,6 @@ mod tests {
});
}
#[test]
fn permission_denied_refuses_without_spawning() {
rt().block_on(async {
let port = echo_target().await;
let (tx, mut rx) = mpsc::unbounded_channel();
let mut mux = PortForwardMux::new(tx, format!("127.0.0.1:{}", port));
mux.handle(open(1, port), || false);
assert_eq!(opened(&next_frame(&mut rx).await), (1, false));
assert_eq!(mux.live_channels(), 0);
});
}
#[test]
fn a_revoked_permission_refuses_new_channels_and_keeps_live_ones() {
rt().block_on(async {
@@ -409,22 +392,6 @@ mod tests {
});
}
#[test]
fn close_while_connecting_sends_no_opened() {
rt().block_on(async {
// `close` is queued before the task is first polled. Its `select!` is
// biased towards the command arm, so even a connect that completes on
// that same poll loses: no `opened` may ever be sent.
let port = echo_target().await;
let (tx, mut rx) = mpsc::unbounded_channel();
let mut mux = PortForwardMux::new(tx, format!("127.0.0.1:{}", port));
mux.handle(open(1, port), || true);
mux.handle(close(1), || true);
assert!(tokio::time::timeout(std::time::Duration::from_millis(200), rx.recv()).await.is_err());
assert_eq!(mux.live_channels(), 0);
});
}
#[test]
fn over_window_data_closes_only_that_channel() {
rt().block_on(async {
@@ -496,38 +463,6 @@ mod tests {
});
}
#[test]
fn demux_admits_only_the_initial_window_before_opened() {
rt().block_on(async {
let port = echo_target().await;
let (tx, mut rx) = mpsc::unbounded_channel();
let mut mux = PortForwardMux::new(tx, format!("127.0.0.1:{}", port));
mux.handle(open(1, port), || true);
// The channel task has not run yet: the demultiplexer alone
// decides what may sit in the queue before `opened`.
assert_eq!(mux.recv_window_remaining(1), Some(INITIAL_WINDOW));
assert_eq!(opened(&next_frame(&mut rx).await), (1, true));
assert_eq!(mux.recv_window_remaining(1), Some(CHANNEL_WINDOW));
});
}
#[test]
fn pending_bytes_are_bounded_by_initial_window_before_opened() {
// A loopback connect completes before a task can observe "connecting",
// so the bound is pinned on the pure predicate the task uses.
assert!(pending_fits(0, INITIAL_WINDOW as usize));
assert!(pending_fits(
INITIAL_WINDOW as usize - MIN_FRAME_CHARGE as usize,
1
));
// A 1-byte frame costs a whole minimum charge here too.
assert!(!pending_fits(
INITIAL_WINDOW as usize - MIN_FRAME_CHARGE as usize + 1,
1
));
assert!(!pending_fits(usize::MAX, 1));
}
/// A target that accepts and hangs up at once, so every channel ends on
/// the target's EOF — the case where only the next `open` frees the entry.
async fn drop_target() -> u16 {
-5
View File
@@ -1596,11 +1596,6 @@ pub struct FrameInfo {
mod tests {
use super::{is_valid_capture_frame_length, ADDR_CAPTURE_FRAME};
#[test]
fn test_is_valid_capture_frame_length_rejects_zero_length() {
assert!(!is_valid_capture_frame_length(ADDR_CAPTURE_FRAME + 1024, 0));
}
#[test]
fn test_is_valid_capture_frame_length_rejects_out_of_bounds_length() {
assert!(!is_valid_capture_frame_length(ADDR_CAPTURE_FRAME + 16, 17));
+1 -66
View File
@@ -2087,51 +2087,7 @@ impl TerminalServiceProxy {
#[cfg(test)]
mod tests {
use super::{find_utf8_split_point, OutputBuffer, Utf8ChunkAccumulator, MAX_BUFFER_LINES};
#[test]
fn utf8_split_point_returns_full_len_for_complete_input() {
assert_eq!(find_utf8_split_point(b"hello"), 5);
assert_eq!(find_utf8_split_point("中文".as_bytes()), "中文".len());
assert_eq!(find_utf8_split_point("😀".as_bytes()), "😀".len());
}
#[test]
fn utf8_split_point_detects_incomplete_trailing_sequence() {
let data = [b'a', 0xE4, 0xB8];
assert_eq!(find_utf8_split_point(&data), 1);
}
#[test]
fn utf8_split_point_keeps_malformed_prefix_but_buffers_trailing_lead_byte() {
let data = [0xFF, 0xE4];
assert_eq!(find_utf8_split_point(&data), 1);
}
#[test]
fn utf8_split_point_treats_orphan_continuations_as_complete() {
let data = [0x80, 0x81, 0x82];
assert_eq!(find_utf8_split_point(&data), data.len());
}
#[test]
fn utf8_chunk_accumulator_reassembles_split_multibyte_output() {
let full = "你好世界".as_bytes();
let mut chunker = Utf8ChunkAccumulator::default();
let mut output = Vec::new();
for chunk in full.chunks(5) {
if let Some(data) = chunker.push_chunk(chunk.to_vec()) {
output.extend_from_slice(&data);
}
}
if let Some(data) = chunker.finish() {
output.extend_from_slice(&data);
}
assert_eq!(output, full);
}
use super::{OutputBuffer, Utf8ChunkAccumulator, MAX_BUFFER_LINES};
#[test]
fn utf8_chunk_accumulator_buffers_leading_split_multibyte_output() {
@@ -2154,27 +2110,6 @@ mod tests {
assert!(chunker.finish().is_none());
}
#[test]
fn utf8_chunk_accumulator_does_not_stall_on_malformed_bytes() {
let mut chunker = Utf8ChunkAccumulator::default();
assert_eq!(chunker.push_chunk(vec![0xFF]), Some(vec![0xFF]));
assert!(chunker.finish().is_none());
}
#[test]
fn utf8_chunk_accumulator_buffers_lone_utf8_lead_bytes() {
let mut chunker = Utf8ChunkAccumulator::default();
assert!(chunker.push_chunk(vec![0xE4]).is_none());
assert_eq!(chunker.finish(), Some(vec![0xE4]));
}
#[test]
fn utf8_chunk_accumulator_does_not_hold_back_non_utf8_prefixes() {
let mut chunker = Utf8ChunkAccumulator::default();
assert_eq!(chunker.push_chunk(vec![0xFF, 0xE4]), Some(vec![0xFF, 0xE4]));
assert!(chunker.finish().is_none());
}
#[test]
fn output_buffer_trim_after_incomplete_merge_does_not_underflow() {
let mut buffer = OutputBuffer::new();
-44
View File
@@ -897,15 +897,6 @@ mod tests {
qos
}
#[test]
fn isolated_delay_spike_does_not_lower_fps() {
let mut qos = stable_qos();
for delay in [800, 10, 10, 10] {
qos.user_network_delay(1, delay);
assert_eq!(qos.fps(), FPS);
}
}
#[test]
fn occasional_spikes_do_not_accumulate_congestion() {
let mut qos = stable_qos();
@@ -915,15 +906,6 @@ mod tests {
}
}
#[test]
fn sustained_delay_reduces_fps_gradually() {
let mut qos = stable_qos();
for expected_fps in [30, 30, 24, 24, 24, 20] {
qos.user_network_delay(1, 800);
assert_eq!(qos.fps(), expected_fps);
}
}
#[test]
fn delay_history_keeps_two_samples() {
let mut delay = UserDelay::default();
@@ -933,22 +915,6 @@ mod tests {
assert_eq!(delay.delay_history.len(), HISTORY_DELAY_LEN);
}
#[test]
fn response_timeout_halves_fps_for_each_second_outstanding() {
let mut qos = stable_qos();
for (elapsed, expected) in [(2001, 15), (3001, 7), (4001, 5), (5001, 5), (6001, 5)] {
qos.user_delay_response_elapsed(1, elapsed);
assert_eq!(qos.fps(), expected, "{elapsed} ms outstanding");
}
}
#[test]
fn severe_delay_does_not_wait_for_another_reply() {
let mut qos = stable_qos();
qos.user_network_delay(1, 1200);
assert_eq!(qos.fps(), 15);
}
#[test]
fn response_timeout_recovers_in_two_good_replies() {
let mut qos = stable_qos();
@@ -1003,20 +969,10 @@ mod tests {
assert_eq!(qos.fps(), FPS, "ordinary recovery can still reach the cap");
}
#[test]
fn custom_fps_limit_applies_during_delay_spike() {
let mut qos = stable_qos();
qos.user_custom_fps(1, 12);
qos.user_network_delay(1, 800);
assert_eq!(qos.fps(), 12);
}
mod adaptation;
mod baseline;
mod invariants;
mod jitter;
mod recovery;
mod robustness;
mod sim;
mod smoke;
mod startup;
-262
View File
@@ -1,208 +1,5 @@
//! Regression tests for sustained capacity drops and path-delay/content changes.
//! Capacity drops use the closed-loop model; delay and activity fixtures are open-loop.
use super::*;
fn percentile(values: &[f64], p: f64) -> f64 {
assert!(!values.is_empty());
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted[((sorted.len() - 1) as f64 * p).round() as usize]
}
// Count a fall and subsequent rise of at least `amplitude`, ignoring smaller
// reversals. A one-way reduction or recovery is not an oscillation.
fn round_trips(values: &[f64], amplitude: f64) -> usize {
let mut peak = values[0];
let mut trough = peak;
let mut falling = false;
let mut count = 0;
for &value in &values[1..] {
if falling {
trough = trough.min(value);
if value - trough >= amplitude {
count += 1;
peak = value;
falling = false;
}
} else {
peak = peak.max(value);
if peak - value >= amplitude {
trough = value;
falling = true;
}
}
}
count
}
#[test]
fn oscillation_metric_distinguishes_recovery_and_small_jitter() {
assert_eq!(round_trips(&[30.0, 29.0, 30.0, 28.0, 30.0], 7.5), 0);
assert_eq!(round_trips(&[30.0, 20.0, 10.0], 7.5), 0);
assert_eq!(round_trips(&[10.0, 20.0, 30.0], 7.5), 0);
assert_eq!(round_trips(&[30.0, 10.0, 30.0, 20.0, 30.0], 7.5), 2);
assert_eq!(round_trips(&[0.49, 0.17, 0.49], 0.67 * 0.25), 1);
}
struct Oscillation {
mean_fps: f64,
mean_ratio: f64,
fps_span: f64,
ratio_span: f64,
fps_cycles_per_min: f64,
ratio_cycles_per_min: f64,
queue_p95_ms: f64,
}
fn permanent_drop(seeds: std::ops::RangeInclusive<u64>) {
use super::sim::{self, Summary};
const STEADY_START_MS: u32 = 120_000;
const END_MS: u32 = 600_000;
let cases = [
("bandwidth_halved_30", 3000.0),
("bandwidth_halved_fixed_rate_30", 2000.0),
("bandwidth_halved_fixed_rate_no_abr_30", 3000.0),
];
println!("Permanent drop: 8 -> 2.5 Mbps at 60 s; duration 600 s; seeds {seeds:?}.");
println!("Oscillation/queue window: 120-600 s. Spans are p95-p5; round trips require 25% of the requested FPS/ratio in each direction. Delivered FPS/frame age cover 15-600 s.");
println!("| scenario | capacity wobble | steady mean FPS (median) | mean ratio (median) | FPS span (p90) | ratio span (p90) | FPS cycles/min (p90) | ratio cycles/min (p90) | queue p95 (p90) | delivered FPS (median) | frame age p95 (p90) |");
println!("|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|");
let mut cases_run = 0;
for mut sc in sim::scenarios() {
let Some((_, queue_bound_ms)) = cases.iter().find(|(name, _)| *name == sc.name) else {
continue;
};
cases_run += 1;
sc.seconds = END_MS / 1000;
sc.link.capacity_kbps = vec![(0, 8000.0), (60_000, 2500.0)];
let original_wobble = sc.link.wobble;
for wobble in [0.0, original_wobble] {
sc.link.wobble = wobble;
let mut reports = Vec::new();
let mut oscillations = Vec::new();
for seed in seeds.clone() {
sc.seed = seed;
let report = sim::run(&sc);
assert!(
report
.trace
.iter()
.filter(|(t, ..)| (30_000..60_000).contains(t))
.all(|(_, fps, _, _)| *fps == sc.limit),
"healthy pre-drop phase: {} seed {seed}",
sc.name
);
let steady: Vec<_> = report
.trace
.iter()
.filter(|(t, ..)| *t >= STEADY_START_MS)
.collect();
let fps: Vec<_> = steady.iter().map(|(_, fps, _, _)| *fps as f64).collect();
let ratios: Vec<_> = steady
.iter()
.map(|(_, _, _, ratio)| *ratio as f64)
.collect();
let queues: Vec<_> = steady
.iter()
.map(|(_, _, queue, _)| *queue as f64)
.collect();
let minutes = (END_MS - STEADY_START_MS) as f64 / 60_000.0;
oscillations.push(Oscillation {
mean_fps: fps.iter().sum::<f64>() / fps.len() as f64,
mean_ratio: ratios.iter().sum::<f64>() / ratios.len() as f64,
fps_span: percentile(&fps, 0.95) - percentile(&fps, 0.05),
ratio_span: percentile(&ratios, 0.95) - percentile(&ratios, 0.05),
fps_cycles_per_min: round_trips(&fps, sc.limit as f64 * 0.25) as f64 / minutes,
ratio_cycles_per_min: round_trips(&ratios, sc.quality.ratio() as f64 * 0.25)
as f64
/ minutes,
queue_p95_ms: percentile(&queues, 0.95),
});
reports.push(report);
}
let metric = |field: fn(&Oscillation) -> f64, p| {
percentile(&oscillations.iter().map(field).collect::<Vec<_>>(), p)
};
let summary = Summary::of(&reports);
let queue_p95 = metric(|o| o.queue_p95_ms, 0.9);
println!("| {} | {:.0}% | {:.1} | {:.3} | {:.1} | {:.3} | {:.2} | {:.2} | {:.0} ms | {:.1} | {} ms |",
sc.name, wobble * 100.0, metric(|o| o.mean_fps, 0.5),
metric(|o| o.mean_ratio, 0.5),
metric(|o| o.fps_span, 0.9), metric(|o| o.ratio_span, 0.9),
metric(|o| o.fps_cycles_per_min, 0.9), metric(|o| o.ratio_cycles_per_min, 0.9),
queue_p95, summary.delivered_median, summary.frame_age_p95_p90);
// Queue and frame-age limits reuse the transient-drop budgets;
// oscillation statistics are diagnostic.
assert!(
queue_p95 < *queue_bound_ms,
"{}: steady queue {queue_p95} ms",
sc.name
);
assert!(
(summary.frame_age_p95_p90 as f64) < *queue_bound_ms,
"{summary:?}"
);
assert!(
summary.delivered_median >= sc.limit as f64 * 0.4,
"{summary:?}"
);
}
}
assert_eq!(cases_run, cases.len());
}
#[test]
fn permanent_capacity_drop_600s() {
permanent_drop(super::sim::SEEDS);
}
#[test]
#[ignore = "extended permanent-drop coverage over 100 held-out seeds"]
fn permanent_capacity_drop_held_out_seeds() {
permanent_drop(21..=120);
}
#[test]
fn low_capacity_preserves_auto_floor_and_recovers() {
let mut sc = super::sim::scenarios()
.into_iter()
.find(|s| s.name == "bandwidth_halved_fixed_rate_no_abr_30")
.unwrap();
sc.link.capacity_kbps = vec![(0, 8000.0), (60_000, 700.0), (120_000, 8000.0)];
for seed in super::sim::SEEDS {
sc.seed = seed;
let report = super::sim::run(&sc);
assert!(
report.trace.iter().all(|(_, fps, ..)| *fps >= 5),
"seed {seed}: automatic reductions went below 5 FPS"
);
let congested: Vec<_> = report
.trace
.iter()
.filter(|(t, ..)| (80_000..120_000).contains(t))
.collect();
let min_fps = congested.iter().map(|(_, fps, ..)| *fps).min().unwrap();
let min_queue = congested
.iter()
.map(|(_, _, queue, _)| *queue)
.min()
.unwrap();
assert_eq!(
min_fps, 5,
"seed {seed}: severe congestion must reach the floor"
);
// At 5 FPS this model sends about 670 kbps, leaving little room to drain
// existing backlog at 700 kbps. Require drainage after capacity returns.
assert!(
report.recovery_ms.is_some_and(|ms| ms <= 20_000),
"seed {seed}: recovery took {:?}",
report.recovery_ms
);
println!("700 kbps fixed-rate, seed {seed}: min FPS={min_fps}, min queue={min_queue} ms, queue p95={} ms, frame age p95={} ms, recovery={:?}", report.queue_p95_ms, report.frame_age_p95_ms, report.recovery_ms);
}
}
const DISPLAY: &str = "adaptation";
fn session(abr: bool) -> VideoQoS {
@@ -229,65 +26,6 @@ fn second(qos: &mut VideoQoS, delay: u32, dynamic: bool) {
sync_bitrate(qos);
}
fn baseline_steps() -> Vec<String> {
let mut unmet = Vec::new();
println!("Baseline step: 90 s at 10 ms, 180 s at new RTT, 90 s at 10 ms; one fresh reply per second. Relearning requires FPS=30 and excess<150 ms throughout the final 60 s at the new RTT.");
println!("| ABR | new RTT | cold-start final FPS | learned baseline | final excess | final FPS | final ratio | relearned | returned-path FPS |");
println!("|---|---:|---:|---:|---:|---:|---:|---|---:|");
for abr in [false, true] {
for rtt in [310, 410] {
let mut cold = session(abr);
for _ in 0..90 {
second(&mut cold, rtt, true);
assert!(cold.fps() >= INIT_FPS, "stable cold-start RTT {rtt}");
}
assert_eq!(cold.fps(), FPS);
let mut qos = session(abr);
for _ in 0..90 {
second(&mut qos, 10, true);
}
assert_eq!(qos.fps(), FPS);
let mut relearned = true;
for s in 0..180 {
second(&mut qos, rtt, true);
if s >= 120 {
let base = qos.users[&1].delay.rtt_calculator.get_rtt().unwrap();
relearned &= qos.fps() == FPS && rtt.saturating_sub(base) < 150;
}
}
let base = qos.users[&1].delay.rtt_calculator.get_rtt().unwrap();
let high_fps = qos.fps();
let high_ratio = qos.ratio();
for _ in 0..90 {
second(&mut qos, 10, true);
}
assert_eq!(
qos.fps(),
FPS,
"return to the original path: ABR={abr} RTT={rtt}"
);
assert!(qos.ratio() >= BR_BALANCED * 0.95);
println!("| {abr} | {rtt} ms | {} | {base} ms | {} ms | {high_fps} | {high_ratio:.3} | {relearned} | {} |",
cold.fps(), rtt.saturating_sub(base), qos.fps());
if !relearned {
unmet.push(format!(
"ABR={abr}, RTT 10 -> {rtt} ms: base={base}, FPS={high_fps}"
));
}
}
}
unmet
}
#[test]
fn baseline_step_relearns_higher_rtt() {
let unmet = baseline_steps();
assert!(
unmet.is_empty(),
"higher baseline was not relearned: {unmet:?}"
);
}
#[test]
fn static_to_dynamic_ratio_recovery() {
println!("Static recovery: 90 s healthy video, 12 s confirmed 800 ms delay, 60 s healthy static screen, then 90 s video. Bitrate is modeled as ratio * 6000 kbps.");
-45
View File
@@ -8,25 +8,6 @@ fn learned_baseline(delay: u32) -> RttCalculator {
rtt
}
#[test]
fn old_minimum_expires_after_a_stable_path_change() {
for (old, new) in [(10, 310), (10, 500), (159, 500)] {
let mut rtt = learned_baseline(old);
for i in 0..40 {
let before = rtt.get_rtt().unwrap();
rtt.update(new + i % 5 * 10);
let after = rtt.get_rtt().unwrap();
assert!(after <= before + 50, "limit the cost of relearning");
if i < 10 {
assert_eq!(after, old, "a short burst must not replace the baseline");
}
}
assert_eq!(rtt.get_rtt(), Some(new), "{old} -> {new}");
rtt.update(old);
assert_eq!(rtt.get_rtt(), Some(old), "a lower delay is direct evidence");
}
}
#[test]
fn rising_delay_is_not_learned_as_a_new_baseline() {
for step in [2, 10, 50] {
@@ -38,32 +19,6 @@ fn rising_delay_is_not_learned_as_a_new_baseline() {
}
}
#[test]
fn intermittent_spikes_do_not_raise_the_baseline() {
let mut rtt = learned_baseline(159);
for delay in [159, 900, 500, 159, 350].repeat(30) {
rtt.update(delay);
assert_eq!(rtt.get_rtt(), Some(159));
}
}
#[test]
fn pending_probes_and_late_replies_do_not_age_the_baseline() {
let mut qos = stable_qos();
for elapsed in (2001..122_001).step_by(1000) {
qos.user_delay_response_elapsed(1, elapsed);
assert_eq!(qos.users[&1].delay.rtt_calculator.get_rtt(), Some(10));
}
qos.user_network_delay(1, 122_000);
assert_eq!(qos.users[&1].delay.rtt_calculator.get_rtt(), Some(10));
for _ in 0..30 {
qos.user_delay_response_elapsed(1, 2500);
qos.user_network_delay(1, 2600);
}
assert_eq!(qos.users[&1].delay.rtt_calculator.get_rtt(), Some(10));
assert_eq!(qos.fps(), 5);
}
#[test]
fn stable_path_change_recovers_without_reconnecting() {
println!("| ABR | path delay | seconds to 30 FPS | final baseline | final FPS |");
-498
View File
@@ -1,498 +0,0 @@
//! The controller's invariants as properties over random sessions. A scenario
//! test pins one trajectory; these hold whatever the trajectory:
//!
//! 1. viewer isolation: a viewer's private target is a function of its own
//! replies, timeouts and limit, never of another viewer's (with ABR on, the
//! shared bitrate state is the one designed input: the frame rate keeps a
//! floor while the bitrate can still come down);
//! 2. bad evidence never raises anything: a bad reply or a timeout tick keeps or
//! lowers that viewer's target and the bitrate ratio;
//! 3. lifecycle: a join adds a constraint and a leave removes it, and neither
//! touches any other viewer's state;
//! 4. evidence ownership: a bitrate cut is asked for by a viewer's own evidence,
//! by the step that viewer's own evidence calls for, and a newcomer's first
//! reply does not spend that evidence again;
//! 5. caps: a reply leaves the target within `[MIN_FPS, cap]`, and the stream is
//! the aggregation of the targets, the caps and the start-up guards;
//! 6. pairing: the late reply of a braked probe does not brake again.
use super::*;
/// xorshift64*, so the tests need no external crate and stay reproducible.
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
Rng((seed ^ 0x9E37_79B9_7F4A_7C15).max(1))
}
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
fn below(&mut self, n: u64) -> u64 {
self.next() % n
}
fn chance(&mut self, pct: u64) -> bool {
self.below(100) < pct
}
}
#[derive(Debug, Clone, Copy)]
enum Step {
Reply { id: i32, delay: u32 },
Timeout { id: i32, elapsed: u128 },
Wait(u64),
Tick(usize),
Cap { id: i32, fps: u32 },
}
const SEEDS: u64 = 150;
const STEPS: usize = 300;
/// Random events for a set of viewers on one link. A probe that is out stays
/// out until a reply: the connection reports a growing elapsed time every second
/// and the reply that ends the stall carries at least that delay.
struct Driver {
rng: Rng,
ids: Vec<i32>,
base_rtt: u32,
outstanding: HashMap<i32, u128>,
}
impl Driver {
fn new(seed: u64, ids: Vec<i32>) -> Self {
let mut rng = Rng::new(seed);
let base_rtt = 10 + rng.below(300) as u32;
Driver {
rng,
ids,
base_rtt,
outstanding: HashMap::new(),
}
}
fn step(&mut self) -> Step {
let id = self.ids[self.rng.below(self.ids.len() as u64) as usize];
let roll = self.rng.below(100);
match roll {
0..=64 => {
let mut delay = if roll < 45 {
self.base_rtt + self.rng.below(140) as u32
} else {
self.base_rtt + DELAY_THRESHOLD_150MS + self.rng.below(1500) as u32
};
if let Some(elapsed) = self.outstanding.remove(&id) {
delay = delay.max(elapsed as u32 + self.rng.below(500) as u32);
}
Step::Reply { id, delay }
}
65..=74 => {
let elapsed = match self.outstanding.get(&id) {
Some(elapsed) => elapsed + 1000,
None => 2001 + self.rng.below(1000) as u128,
};
self.outstanding.insert(id, elapsed);
Step::Timeout { id, elapsed }
}
75..=89 => Step::Wait(self.rng.below(1500)),
90..=96 => Step::Tick(self.rng.below(31) as usize),
_ => Step::Cap {
id,
fps: 1 + self.rng.below(60) as u32,
},
}
}
}
/// What `on_connection_open` inserts, without touching the config store.
fn open(qos: &mut VideoQoS, id: i32) {
qos.users.insert(
id,
UserData {
joined_at: Some(qos.now()),
..Default::default()
},
);
}
fn session(abr: bool) -> VideoQoS {
let mut qos = VideoQoS::default();
qos.advance_ms(2000);
qos.abr_config = abr;
qos.first_reply_adjusts_ratio = true;
qos.new_display("test".to_owned());
qos.set_support_changing_quality("test", true);
qos.store_bitrate(4000);
qos
}
/// The video loop reports the encoder's bitrate as soon as it applies a ratio.
fn sync_bitrate(qos: &mut VideoQoS) {
let target = qos.latest_quality().ratio();
let ratio = qos.ratio();
qos.store_bitrate((4000.0 * ratio / target) as u32);
}
fn apply(qos: &mut VideoQoS, step: Step) {
match step {
Step::Reply { id, delay } => qos.user_network_delay(id, delay),
Step::Timeout { id, elapsed } => qos.user_delay_response_elapsed(id, elapsed),
Step::Wait(ms) => qos.advance_ms(ms),
Step::Tick(encoded) => qos.update_display_data("test", encoded),
Step::Cap { id, fps } => qos.user_custom_fps(id, fps),
}
sync_bitrate(qos);
}
/// The viewer's private target, as the controller reads it before a reply.
fn target(qos: &VideoQoS, id: i32) -> u32 {
let user = &qos.users[&id];
user.delay.fps.unwrap_or(INIT_FPS.min(user.fps_cap()))
}
fn baseline(qos: &VideoQoS, id: i32) -> Option<u32> {
qos.users[&id].delay.rtt_calculator.get_rtt()
}
/// Everything the controller keeps about a viewer, for change detection.
fn snapshot(qos: &VideoQoS, id: i32) -> String {
format!("{:?}", qos.users[&id])
}
/// The aggregation `adjust_fps` is meant to compute: the slowest viewer's target,
/// INIT_FPS for a viewer without a reply or inside its first second, within the
/// lowest cap.
fn expected_stream(qos: &VideoQoS) -> u32 {
let mut fps = qos
.users
.values()
.map(|u| u.delay.fps.unwrap_or(INIT_FPS))
.min()
.unwrap_or(INIT_FPS);
if qos
.users
.values()
.any(|u| u.joined_at.is_some_and(|j| qos.since(j).as_secs() < 1))
{
fps = fps.min(INIT_FPS);
}
let cap = qos
.users
.values()
.map(|u| u.fps_cap())
.min()
.unwrap_or(FPS);
fps.clamp(MIN_FPS, cap)
}
// Invariant 2: bad evidence never raises a target or the ratio.
#[test]
fn bad_evidence_never_raises_a_target_or_the_ratio() {
for seed in 0..SEEDS {
for abr in [false, true] {
let mut qos = session(abr);
let ids: Vec<i32> = (1..=1 + (seed % 3) as i32).collect();
for id in &ids {
open(&mut qos, *id);
}
let mut driver = Driver::new(seed, ids);
for step_no in 0..STEPS {
let step = driver.step();
let ratio_before = qos.ratio();
let before = match step {
Step::Reply { id, .. } | Step::Timeout { id, .. } => Some((id, target(&qos, id))),
_ => None,
};
apply(&mut qos, step);
// Bad by the baseline the controller used for this reply: the reply
// itself may have relearned it.
let bad = match step {
Step::Reply { id, delay } => baseline(&qos, id)
.is_some_and(|base| delay >= base + DELAY_THRESHOLD_150MS),
Step::Timeout { .. } => true,
_ => false,
};
if let (true, Some((id, before))) = (bad, before) {
let after = target(&qos, id);
assert!(
after <= before,
"seed {seed} abr {abr} step {step_no} {step:?}: target {before} -> {after}"
);
assert!(
qos.ratio() <= ratio_before,
"seed {seed} abr {abr} step {step_no} {step:?}: ratio {ratio_before} -> {}",
qos.ratio()
);
}
}
}
}
}
// Invariant 2 and 6: a timeout tick keeps or lowers the target, whatever it is.
#[test]
fn a_timeout_keeps_or_lowers_every_target() {
for reference in MIN_FPS..=MAX_FPS {
for elapsed in [2001, 2999, 3000, 3001, 4500, 6001, 9000, 30_000] {
let mut qos = session(false);
open(&mut qos, 1);
qos.user_custom_fps(1, MAX_FPS);
qos.users.get_mut(&1).unwrap().delay.fps = Some(reference);
qos.adjust_fps();
let stream = qos.fps();
qos.user_delay_response_elapsed(1, elapsed);
assert!(
target(&qos, 1) <= reference,
"{elapsed} ms outstanding at {reference} fps: target {}",
target(&qos, 1)
);
assert!(
qos.fps() <= stream,
"{elapsed} ms outstanding at {reference} fps: stream {stream} -> {}",
qos.fps()
);
}
}
}
// Invariant 6: the late reply of a braked probe does not brake again.
#[test]
fn a_late_reply_after_a_brake_does_not_brake_again() {
for reference in (MIN_FPS + 1..=MAX_FPS).step_by(3) {
for elapsed in [2001u32, 3001, 4500, 6001, 9000] {
for abr in [false, true] {
let mut qos = session(abr);
open(&mut qos, 1);
qos.user_custom_fps(1, MAX_FPS);
for _ in 0..3 {
qos.user_network_delay(1, 20);
}
qos.users.get_mut(&1).unwrap().delay.fps = Some(reference);
qos.user_delay_response_elapsed(1, elapsed as u128);
let braked = target(&qos, 1);
qos.user_network_delay(1, elapsed + 100);
assert_eq!(
target(&qos, 1),
braked,
"abr {abr}, {elapsed} ms outstanding at {reference} fps"
);
}
}
}
}
/// Viewer 1's target after each of its own events, alone or with company whose
/// events are interleaved: a second viewer with its own replies, timeouts, waits
/// and limits, and a third that joins and leaves along the way. The display
/// timer is left out: on a dynamic screen it raises the ratio off its floor,
/// and the property holds the bitrate state fixed.
fn viewer_one_targets(seed: u64, company: bool, abr: bool, at_floor: bool) -> Vec<u32> {
let mut own = Driver::new(seed, vec![1]);
let mut others = Driver::new(seed ^ 0xC0FF_EE, vec![2]);
let mut qos = session(abr);
if at_floor {
qos.ratio = qos.min_ratio();
sync_bitrate(&mut qos);
assert!(!qos.can_reduce_bitrate());
}
open(&mut qos, 1);
if company {
open(&mut qos, 2);
}
let no_tick = |step: Step| match step {
Step::Tick(_) => Step::Wait(1000),
step => step,
};
let mut targets = Vec::new();
for step_no in 0..STEPS {
if company {
for _ in 0..others.rng.below(3) {
let step = no_tick(others.step());
apply(&mut qos, step);
}
if step_no == STEPS / 3 {
open(&mut qos, 3);
others.ids.push(3);
}
if step_no == 2 * STEPS / 3 {
qos.on_connection_close(3);
others.ids.pop();
}
}
let step = no_tick(own.step());
apply(&mut qos, step);
targets.push(target(&qos, 1));
}
targets
}
// Invariant 1: another viewer's replies, timeouts, limits, joins and leaves do
// not change a viewer's private target. With ABR off the bitrate is fixed; with
// ABR on the shared bitrate state is a designed input, so the property is checked
// at the bitrate floor, where it can no longer change.
#[test]
fn a_viewers_target_is_independent_of_other_viewers() {
for seed in 0..SEEDS {
for (abr, at_floor) in [(false, false), (true, true)] {
let alone = viewer_one_targets(seed, false, abr, at_floor);
let with_company = viewer_one_targets(seed, true, abr, at_floor);
assert_eq!(
alone, with_company,
"seed {seed} abr {abr}: viewer 1's targets differ with company"
);
}
}
}
// Invariant 3: a join adds a constraint, a leave removes it, and neither touches
// another viewer's state.
#[test]
fn joins_and_leaves_only_change_the_aggregation() {
for seed in 0..SEEDS {
for abr in [false, true] {
let mut qos = session(abr);
open(&mut qos, 1);
open(&mut qos, 2);
let mut driver = Driver::new(seed, vec![1, 2]);
let mut next_id = 3;
let mut present: Vec<i32> = Vec::new();
for step_no in 0..STEPS {
let step = driver.step();
apply(&mut qos, step);
if driver.rng.chance(10) {
let others: Vec<i32> = qos.users.keys().copied().collect();
let before: Vec<String> = others.iter().map(|id| snapshot(&qos, *id)).collect();
qos.adjust_fps();
let stream = qos.fps();
open(&mut qos, next_id);
present.push(next_id);
driver.ids.push(next_id);
next_id += 1;
qos.adjust_fps();
assert!(
qos.fps() <= stream,
"seed {seed} abr {abr} step {step_no}: a join raised the stream {stream} -> {}",
qos.fps()
);
assert_eq!(qos.fps(), expected_stream(&qos));
let after: Vec<String> = others.iter().map(|id| snapshot(&qos, *id)).collect();
assert_eq!(
before, after,
"seed {seed} abr {abr} step {step_no}: a join changed a viewer"
);
} else if !present.is_empty() && driver.rng.chance(10) {
let leaving = present.remove(driver.rng.below(present.len() as u64) as usize);
driver.ids.retain(|id| *id != leaving);
driver.outstanding.remove(&leaving);
let others: Vec<i32> = qos.users.keys().copied().filter(|id| *id != leaving).collect();
let before: Vec<String> = others.iter().map(|id| snapshot(&qos, *id)).collect();
qos.on_connection_close(leaving);
let after: Vec<String> = others.iter().map(|id| snapshot(&qos, *id)).collect();
assert_eq!(
before, after,
"seed {seed} abr {abr} step {step_no}: a leave changed a viewer"
);
assert_eq!(
qos.fps(),
expected_stream(&qos),
"seed {seed} abr {abr} step {step_no}: the stream after a leave"
);
}
}
}
}
}
// Invariant 4: the ratio comes down only when a viewer's own evidence asks for
// it, by that viewer's own step, and a newcomer's first reply does not spend the
// evidence again.
#[test]
fn a_bitrate_cut_is_owned_by_a_viewers_evidence() {
let mut cuts = 0;
for seed in 0..SEEDS {
let mut qos = session(true);
let ids: Vec<i32> = (1..=1 + (seed % 3) as i32).collect();
for id in &ids {
open(&mut qos, *id);
}
let mut driver = Driver::new(seed, ids);
for step_no in 0..STEPS {
let step = driver.step();
let before = qos.ratio();
apply(&mut qos, step);
let after = qos.ratio();
if after >= before {
continue;
}
cuts += 1;
let asked: Vec<f32> = qos
.users
.values()
.filter_map(|u| u.delay.ratio_reduction())
.collect();
assert!(
!asked.is_empty(),
"seed {seed} step {step_no} {step:?}: a cut nobody asked for"
);
let deepest = asked.iter().copied().fold(1.0_f32, f32::min);
assert!(
after >= before * deepest * 0.999,
"seed {seed} step {step_no} {step:?}: cut {before} -> {after}, deepest step asked {deepest}"
);
// A newcomer replying inside the cooldown finds the evidence spent.
open(&mut qos, 99);
qos.advance_ms(driver.rng.below(2900));
qos.user_network_delay(99, driver.base_rtt);
assert_eq!(
qos.ratio(),
after,
"seed {seed} step {step_no}: a newcomer's first reply spent the evidence again"
);
qos.on_connection_close(99);
}
}
assert!(cuts > SEEDS as usize, "only {cuts} cuts across {SEEDS} sessions");
}
// Invariant 5: a reply leaves the target within its cap, and the stream is the
// aggregation of targets, caps and start-up guards after every decision.
#[test]
fn targets_stay_within_caps_and_the_stream_is_their_aggregation() {
for seed in 0..SEEDS {
for abr in [false, true] {
let mut qos = session(abr);
let ids: Vec<i32> = (1..=1 + (seed % 3) as i32).collect();
for id in &ids {
open(&mut qos, *id);
}
let mut driver = Driver::new(seed, ids);
for step_no in 0..STEPS {
let step = driver.step();
apply(&mut qos, step);
match step {
Step::Reply { id, .. } => {
let cap = qos.users[&id].fps_cap();
let t = target(&qos, id);
assert!(
(MIN_FPS..=cap).contains(&t),
"seed {seed} abr {abr} step {step_no} {step:?}: target {t} outside [{MIN_FPS}, {cap}]"
);
}
Step::Wait(_) | Step::Cap { .. } => continue,
_ => {}
}
assert_eq!(
qos.fps(),
expected_stream(&qos),
"seed {seed} abr {abr} step {step_no} {step:?}: the stream is not the aggregation"
);
}
}
}
}
-366
View File
@@ -27,26 +27,6 @@ fn bitrate_reduction_precedes_ordinary_fps_reduction() {
assert!(qos.fps() < FPS);
}
#[test]
fn bitrate_cooldown_defers_ordinary_fps_reduction() {
let mut qos = abr_session();
qos.adjust_ratio_instant = qos.now();
let ratio = qos.ratio();
for _ in 0..3 {
qos.user_network_delay(1, 400);
assert_eq!(qos.fps(), FPS);
assert_eq!(qos.ratio(), ratio);
}
qos.advance_ms(4000);
qos.user_network_delay(1, 400);
assert_eq!(qos.fps(), FPS);
assert!(qos.ratio() < ratio);
qos.user_network_delay(1, 400);
assert_eq!(qos.fps(), FPS);
qos.user_network_delay(1, 400);
assert!(qos.fps() < FPS);
}
#[test]
fn unavailable_abr_or_minimum_bitrate_does_not_prevent_fps_reduction() {
for mode in ["disabled", "unsupported", "minimum"] {
@@ -74,91 +54,6 @@ fn severe_delay_and_timeout_bypass_bitrate_cooldown() {
assert_eq!(qos.fps(), 7);
}
#[test]
fn minimum_bitrate_during_cooldown_does_not_block_fps_reduction() {
// ABR on, ratio at its floor, a good reply cleared the post-reduction counter,
// and the adjustment cooldown has just restarted: bitrate cannot help here.
let mut qos = abr_session();
qos.ratio = BR_MIN_HIGH_RESOLUTION;
qos.user_network_delay(1, 10);
qos.adjust_ratio_instant = qos.now();
for _ in 0..3 {
qos.user_network_delay(1, 400);
}
assert!(qos.fps() < FPS);
}
fn abr_session_from_scratch() -> VideoQoS {
let mut qos = VideoQoS::default();
qos.advance_ms(2000);
qos.users.insert(1, UserData::default());
qos.new_display("test".to_owned());
qos.set_support_changing_quality("test", true);
qos.store_bitrate(4000);
qos
}
/// The video loop reports the encoder's bitrate as soon as it applies a new ratio.
fn sync_bitrate(qos: &mut VideoQoS) {
let target = qos.latest_quality().ratio();
let ratio = qos.ratio();
qos.store_bitrate((4000.0 * ratio / target) as u32);
}
/// One second of wall clock, one probe reply, one display update: what a
/// connection does every second.
fn second(qos: &mut VideoQoS, delay: u32, encoded: usize) {
qos.advance_ms(1000);
sync_bitrate(qos);
qos.user_network_delay(1, delay);
sync_bitrate(qos);
qos.update_display_data("test", encoded);
sync_bitrate(qos);
}
#[test]
fn stable_high_rtt_restores_bitrate() {
for rtt in [180, 300] {
let mut qos = abr_session_from_scratch();
let target = qos.latest_quality().ratio();
for _ in 0..120 {
second(&mut qos, rtt, 30);
}
assert_eq!(qos.fps(), FPS, "rtt {rtt}");
assert!(
qos.ratio() >= target * 0.99,
"rtt {rtt}: ratio {}",
qos.ratio()
);
}
}
#[test]
fn congestion_bitrate_reduction_resets_dynamic_screen_window() {
// A static screen encodes about one frame per second. While the congestion path
// adjusts the ratio at every cooldown, the periodic branch never runs, so the
// encode counter must not keep accumulating across the whole episode.
let mut qos = abr_session();
for _ in 0..10 {
qos.advance_ms(4000);
qos.user_network_delay(1, 10);
qos.user_network_delay(1, 400);
qos.user_network_delay(1, 400);
qos.update_display_data("test", 1);
}
for _ in 0..12 {
qos.advance_ms(1000);
qos.user_network_delay(1, 10);
}
let ratio = qos.ratio();
qos.advance_ms(4000);
qos.update_display_data("test", 1);
assert!(
qos.ratio() <= ratio,
"a static screen must not look dynamic after congestion"
);
}
#[test]
fn a_single_stall_does_not_cut_bitrate() {
// One probe out for 2.5 s, then its late reply: jitter, not congestion.
@@ -187,88 +82,6 @@ fn a_stall_beyond_three_seconds_cuts_bitrate() {
assert!(qos.ratio() < ratio, "still out at the next tick");
}
#[test]
fn stable_high_rtt_does_not_dip_at_start() {
for rtt in [180, 300] {
let mut qos = super::smoke::session(30, Quality::Balanced);
for _ in 0..20 {
qos.advance_ms(1000);
qos.user_network_delay(1, rtt);
assert!(qos.fps() >= INIT_FPS, "rtt {rtt}: {}", qos.fps());
}
assert_eq!(qos.fps(), FPS, "rtt {rtt}");
}
}
#[test]
fn confirmed_severe_congestion_halves_bitrate() {
let mut qos = abr_session();
let ratio = qos.ratio();
qos.user_network_delay(1, 800);
qos.user_network_delay(1, 800); // two bad replies: an ordinary step
let after_first = qos.ratio();
assert!(
after_first < ratio && after_first > ratio * 0.75,
"{after_first}"
);
qos.user_network_delay(1, 800); // three: confirmed
qos.advance_ms(4000);
qos.update_display_data("test", 30);
assert!(qos.ratio() <= after_first * 0.55, "{}", qos.ratio());
}
#[test]
fn fps_holds_its_floor_while_bitrate_can_still_drop() {
// With a bitrate-targeted encoder fewer frames do not mean fewer bytes, so the
// bitrate comes down first and the frame rate keeps its floor meanwhile.
let mut qos = abr_session();
let mut reached_floor = false;
// Keep the queue growing, rather than presenting a stable new path delay.
let mut delay = 400;
for _ in 0..60 {
qos.advance_ms(3000);
second(&mut qos, delay, 30);
delay += 10;
if qos.ratio() > 0.17 {
assert!(
qos.fps() >= 10,
"fps {} at ratio {}",
qos.fps(),
qos.ratio()
);
} else {
reached_floor = true;
break;
}
}
assert!(
reached_floor,
"bitrate must reach its floor: {}",
qos.ratio()
);
for _ in 0..24 {
qos.user_network_delay(1, delay);
delay += 10;
}
assert!(
qos.fps() < 10,
"an exhausted bitrate frees the frame rate: {}",
qos.fps()
);
}
#[test]
fn bitrate_timer_does_not_punish_unconfirmed_spikes_or_stale_averages() {
let mut qos = abr_session();
let ratio = qos.ratio();
for delay in [800, 10, 350, 10, 350, 10].repeat(10) {
qos.user_network_delay(1, delay);
qos.adjust_ratio(false);
assert_eq!(qos.fps(), FPS);
assert_eq!(qos.ratio(), ratio);
}
}
#[test]
fn viewers_confirm_congestion_independently() {
let mut qos = stable_qos();
@@ -312,182 +125,3 @@ fn a_congested_viewer_does_not_lower_another_viewers_target() {
}
assert_eq!(qos.fps(), FPS);
}
#[test]
fn pending_probe_checks_do_not_count_as_fresh_bad_replies() {
let mut qos = stable_qos();
qos.user_network_delay(1, 400);
for elapsed in [1000, 1500, 1900] {
qos.user_delay_response_elapsed(1, elapsed);
assert_eq!(qos.fps(), FPS);
}
qos.user_network_delay(1, 400);
assert_eq!(qos.fps(), FPS);
qos.user_network_delay(1, 10);
qos.user_network_delay(1, 400);
qos.user_network_delay(1, 400);
assert_eq!(qos.fps(), FPS);
}
#[test]
fn recovery_continues_with_intermittent_jitter() {
let mut qos = stable_qos();
for _ in 0..3 {
qos.user_network_delay(1, 1200);
}
assert_eq!(qos.fps(), 5);
for delay in [10, 350].repeat(30) {
qos.user_network_delay(1, delay);
}
assert_eq!(qos.fps(), FPS);
}
#[test]
fn custom_limit_of_one_viewer_does_not_lower_another_viewers_target() {
let mut qos = stable_qos();
qos.users.insert(2, UserData::default());
for _ in 0..30 {
qos.user_network_delay(2, 10);
qos.user_network_delay(1, 10);
}
qos.user_custom_fps(2, 12);
qos.user_network_delay(1, 10);
assert_eq!(qos.fps(), 12, "the stream follows the lowest limit");
assert_eq!(
qos.users[&1].delay.fps,
Some(FPS),
"viewer 1's own target is not a function of viewer 2's limit"
);
qos.on_connection_close(2);
assert_eq!(
qos.fps(),
FPS,
"the stream is back the moment the limit is gone"
);
}
#[test]
fn new_viewers_first_reply_does_not_bypass_bitrate_cooldown() {
let mut qos = abr_session();
for _ in 0..3 {
qos.user_network_delay(1, 800);
}
// Viewer 1 is confirmed and its evidence was spent on a cut a moment ago.
let ratio = qos.ratio();
assert!(ratio < Quality::Balanced.ratio());
qos.users.insert(2, UserData::default());
qos.user_network_delay(2, 10);
assert_eq!(
qos.ratio(),
ratio,
"viewer 2's first reply must not spend viewer 1's evidence again inside the cooldown"
);
}
#[test]
fn new_viewer_does_not_inherit_another_viewers_congested_fps() {
let mut qos = stable_qos();
for _ in 0..2 {
qos.user_network_delay(1, 1200);
}
assert_eq!(qos.fps(), 8);
qos.users.insert(2, UserData::default());
qos.user_network_delay(2, 10);
assert!(
qos.users[&2].delay.fps >= Some(INIT_FPS),
"a new viewer starts from INIT_FPS, not from the congested stream: {:?}",
qos.users[&2].delay.fps
);
}
#[test]
fn unconfirmed_severe_viewer_does_not_amplify_another_viewers_confirmed_mild_congestion() {
let mut qos = abr_session();
qos.users.insert(2, UserData::default());
for _ in 0..30 {
qos.user_network_delay(2, 10);
qos.user_network_delay(1, 10);
}
// The first reply of a new viewer adjusts the ratio and restarts the cooldown.
qos.advance_ms(4000);
let ratio = qos.ratio();
// Viewer 2: mild congestion, confirmed over three replies. Viewer 1: one
// severe spike, never confirmed. Each viewer on its own calls for at most a
// five percent step; together they must not turn into a halving.
qos.user_network_delay(2, 200);
qos.user_network_delay(1, 1200);
qos.user_network_delay(2, 200);
let after_two = qos.ratio();
assert!(after_two < ratio, "viewer 2's second bad reply cuts");
assert!(
after_two >= ratio * 0.94,
"viewer 2's own mild excess is a five percent step, not {after_two}"
);
qos.user_network_delay(2, 200);
qos.advance_ms(4000);
qos.update_display_data("test", 30);
assert!(
qos.ratio() >= after_two * 0.94,
"viewer 1's severity must not be paired with viewer 2's confirmation: {}",
qos.ratio()
);
}
/// What `on_connection_open` inserts, without touching the config store.
fn newcomer(qos: &VideoQoS) -> UserData {
UserData {
joined_at: Some(qos.now()),
..Default::default()
}
}
#[test]
fn closing_a_just_opened_viewer_does_not_throttle_existing_viewers() {
let mut qos = stable_qos();
assert_eq!(qos.fps(), FPS);
qos.users.insert(2, newcomer(&qos));
assert_eq!(
qos.fps(),
FPS,
"nothing changes until the stream is re-aggregated"
);
qos.on_connection_close(2);
assert_eq!(
qos.fps(),
FPS,
"the guard leaves with the viewer that brought it"
);
}
#[test]
fn a_new_viewer_caps_the_stream_at_init_fps_for_a_second() {
let mut qos = stable_qos();
qos.users.insert(2, newcomer(&qos));
qos.user_network_delay(1, 10);
assert_eq!(qos.fps(), INIT_FPS);
qos.advance_ms(1000);
qos.user_network_delay(2, 10);
qos.user_network_delay(1, 10);
assert!(
qos.fps() > INIT_FPS,
"after a second the stream follows the viewers' own targets: {}",
qos.fps()
);
}
#[test]
fn closing_the_latest_newcomer_keeps_an_earlier_newcomers_guard() {
let mut qos = stable_qos();
qos.users.insert(2, newcomer(&qos));
qos.user_network_delay(2, 10);
assert_eq!(qos.fps(), INIT_FPS);
qos.advance_ms(100);
qos.users.insert(3, newcomer(&qos));
qos.advance_ms(100);
qos.on_connection_close(3);
assert_eq!(
qos.fps(),
INIT_FPS,
"viewer 2 is still inside its own start-up window"
);
}
-35
View File
@@ -72,22 +72,6 @@ fn automatic_floor_preserves_lower_custom_limits() {
}
}
#[test]
fn two_good_replies_restore_after_a_severe_stall() {
let mut qos = stable_qos();
qos.user_delay_response_elapsed(1, 5001);
assert_eq!(qos.fps(), 5);
qos.user_network_delay(1, 5100);
assert_eq!(qos.fps(), 5, "the late reply must not brake twice");
qos.user_network_delay(1, 10);
assert!(
(5..FPS).contains(&qos.fps()),
"one good reply is not enough"
);
qos.user_network_delay(1, 10);
assert_eq!(qos.fps(), FPS);
}
#[test]
fn jitter_during_recovery_does_not_discard_the_restore_target() {
let mut qos = stable_qos();
@@ -99,22 +83,3 @@ fn jitter_during_recovery_does_not_discard_the_restore_target() {
}
assert_eq!(qos.fps(), FPS);
}
#[test]
fn a_failed_fast_restore_rolls_back_before_the_queue_grows() {
let mut qos = stable_qos();
qos.user_delay_response_elapsed(1, 5001);
for _ in 0..2 {
qos.user_network_delay(1, 10);
}
assert_eq!(qos.fps(), FPS);
qos.user_network_delay(1, 800);
assert_eq!(qos.fps(), FPS / 2);
for _ in 0..2 {
qos.user_network_delay(1, 10);
}
assert!(
qos.fps() < FPS,
"a failed restore must lower the next probe"
);
}
-180
View File
@@ -1,180 +0,0 @@
//! Guards against tuning the controller to the simulator: the CI bounds applied
//! to seeds that never took part in setting them, and a sweep of the scenario
//! parameters. Both are `#[ignore]`d: they take a few seconds and are meant for
//! anyone changing a controller constant or a bound.
//!
//! ```text
//! cargo test --lib video_qos::tests::robustness -- --ignored --nocapture
//! ```
use super::sim::{bound_violations, run, scenarios, Report, Scenario, Summary};
use super::*;
fn summarise(sc: &Scenario, seeds: impl Iterator<Item = u64>) -> Summary {
let reports: Vec<Report> = seeds
.map(|seed| run(&Scenario { seed, ..sc.clone() }))
.collect();
Summary::of(&reports)
}
/// Seeds 21 to 120 in five blocks of twenty, each block held to the CI bounds.
#[test]
#[ignore]
fn held_out_seeds() {
println!("| scenario | blocks violating | which | median target | worst p10 | below limit/2 (p90) | queue p95 (p90) |");
println!("|---|---:|---|---:|---:|---:|---:|");
let mut failures = Vec::new();
for sc in scenarios() {
let mut failing_blocks = 0;
let mut which: Vec<&str> = Vec::new();
let mut reports: Vec<Report> = Vec::new();
for block in 0..5u64 {
let first = 21 + block * 20;
let block_reports: Vec<Report> = (first..first + 20)
.map(|seed| run(&Scenario { seed, ..sc.clone() }))
.collect();
let s = Summary::of(&block_reports);
reports.extend(block_reports);
let violations = bound_violations(&s);
if !violations.is_empty() {
failing_blocks += 1;
for v in violations {
if !which.contains(&v) {
which.push(v);
}
}
}
}
let all = Summary::of(&reports);
println!(
"| {} | {}/5 | {} | {:.1} | {} | {:.1}% | {} ms |",
sc.name,
failing_blocks,
which.join("; "),
all.mean_target_median,
all.p10_target_worst,
all.below_half_p90,
all.queue_p95_p90
);
if failing_blocks > 0 {
failures.push(format!(
"{} ({} of 5 blocks: {})",
sc.name,
failing_blocks,
which.join("; ")
));
}
}
assert!(
failures.is_empty(),
"bounds fail on held-out seeds, so they were fitted to the CI seeds: {failures:?}"
);
}
/// One scenario parameter at a time, halved and doubled, twenty seeds each. No
/// bounds: the point is to see that nothing falls off a cliff, and to compare with
/// master by running the same test there.
#[test]
#[ignore]
fn sensitivity() {
let base = scenarios();
let pick = |name: &str| base.iter().find(|s| s.name == name).unwrap().clone();
let home = pick("home_wifi_30");
let halved = pick("bandwidth_halved_30");
let with_link = |sc: &Scenario, edit: &dyn Fn(&mut super::sim::Link)| {
let mut link = sc.link.clone();
edit(&mut link);
Scenario { link, ..sc.clone() }
};
let variants: Vec<(&str, Scenario)> = vec![
("home base", home.clone()),
(
"home stalls half as long",
with_link(&home, &|l| l.stall_ms = (150.0, 1250.0)),
),
(
"home stalls twice as long",
with_link(&home, &|l| l.stall_ms = (600.0, 5000.0)),
),
(
"home stalls twice as often",
with_link(&home, &|l| l.stall_mean_interval_s = 10.0),
),
(
"home stalls half as often",
with_link(&home, &|l| l.stall_mean_interval_s = 40.0),
),
(
"home loss doubled",
with_link(&home, &|l| l.loss_per_s = 0.4),
),
(
"home capacity halved",
with_link(&home, &|l| l.capacity_kbps = vec![(0, 10_000.0)]),
),
(
"home capacity 6 Mbps",
with_link(&home, &|l| l.capacity_kbps = vec![(0, 6_000.0)]),
),
(
"home jitter heavier",
with_link(&home, &|l| {
l.jitter_sigma = 1.5;
l.jitter_median_ms = 30.0;
}),
),
(
"home base rtt 80 ms",
with_link(&home, &|l| l.base_rtt_ms = 80.0),
),
(
"home 60 fps limit",
Scenario {
limit: 60,
..home.clone()
},
),
("bandwidth halved base", halved.clone()),
(
"bandwidth to 4 Mbps",
with_link(&halved, &|l| {
l.capacity_kbps = vec![(0, 8_000.0), (60_000, 4_000.0), (120_000, 8_000.0)]
}),
),
(
"bandwidth to 1.5 Mbps",
with_link(&halved, &|l| {
l.capacity_kbps = vec![(0, 8_000.0), (60_000, 1_500.0), (120_000, 8_000.0)]
}),
),
(
"bandwidth halved, never restored, 300 s",
Scenario {
seconds: 300,
..with_link(&halved, &|l| {
l.capacity_kbps = vec![(0, 8_000.0), (60_000, 2_500.0)]
})
},
),
];
println!("| variant | median target | worst p10 | below limit/2 (p90) | queue p95 (p90) | delivered | sustained recovery (worst) |");
println!("|---|---:|---:|---:|---:|---:|---:|");
for (label, sc) in &variants {
let s = summarise(sc, super::sim::SEEDS);
println!(
"| {} | {:.1} | {} | {:.1}% | {} ms | {:.1} | {} |",
label,
s.mean_target_median,
s.p10_target_worst,
s.below_half_p90,
s.queue_p95_p90,
s.delivered_median,
if s.has_restore {
s.recovery_worst_ms
.map(|ms| format!("{:.1}s", ms as f64 / 1000.0))
.unwrap_or_else(|| "never".to_owned())
} else {
"-".to_owned()
}
);
}
}
+3 -81
View File
@@ -24,8 +24,7 @@
//! reacts to the *kind* of behaviour a home Wi-Fi, a stable relay or a saturated
//! uplink produce, deterministically and over many seeds.
//!
//! Against overfitting: the CI run uses seeds 1 to 20; `robustness.rs` applies the
//! same bounds to seeds 21 to 120 and sweeps the scenario parameters. Scenario
//! The CI run uses seeds 1 to 20. Scenario
//! parameters are educated guesses until a recorded `qos_trace` calibrates them.
use super::*;
@@ -296,8 +295,6 @@ pub struct Report {
pub cold_start_min_fps: u32,
/// First time the target reached 90% of the limit.
pub time_to_90pct_ms: Option<u32>,
pub final_fps: u32,
pub final_ratio: f32,
pub trace: Vec<(u32, u32, u32, f32)>, // (time_ms, target fps, queue_ms, ratio)
}
@@ -496,8 +493,6 @@ pub fn run(sc: &Scenario) -> Report {
recovery_ms,
cold_start_min_fps,
time_to_90pct_ms,
final_fps: qos.fps(),
final_ratio: qos.ratio(),
trace,
}
}
@@ -758,8 +753,8 @@ fn sim_scenarios() {
}
}
/// The bounds every scenario summary has to meet, shared by the CI run over `SEEDS`
/// and by the held-out run in `robustness.rs`. They state what the product needs,
/// The bounds every scenario summary has to meet in the CI run over `SEEDS`.
/// They state what the product needs,
/// not what one seed produced. If a new seed or a new scenario violates a bound,
/// change the design or loosen the bound with a written reason; never tune a
/// controller constant until the bound passes.
@@ -913,76 +908,3 @@ fn replay_recorded_trace() {
trace.len()
);
}
#[test]
fn replay_time_axis_is_shared_across_connections() {
// Two viewers each log once a second for twenty seconds: twenty seconds of
// wall clock, not forty.
let text: String = (0..20)
.flat_map(|i| {
[
format!("qos_trace t={} id=1 delay=10 fps=30\n", 100_000 + i * 1000),
format!("qos_trace t={} id=2 delay=10 fps=30\n", 100_001 + i * 1000),
]
})
.collect();
let trace = replay(&text);
let elapsed = trace.last().unwrap().0 - trace.first().unwrap().0;
assert!(
(19_000..=19_100).contains(&elapsed),
"replayed {elapsed} ms for 19 s of wall clock"
);
}
#[test]
fn replay_recorded_trace_is_independent_of_connection_id() {
let replay = |id: i32| {
let text: String = (0..20)
.map(|i| {
format!(
"qos_trace t={} id={id} delay=10 fps=30\n",
100_000 + i * 1000
)
})
.collect();
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"rustdesk-qos-replay-{}-{nonce}-{id}.log",
std::process::id()
));
std::fs::write(&path, text).unwrap();
// Exercise the real replay entry point without changing other tests' environment.
let test = format!(
"{}::replay_recorded_trace",
module_path!().split_once("::").unwrap().1
);
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", &test, "--nocapture", "--test-threads=1"])
.env("RUSTDESK_QOS_TRACE", &path)
.output();
std::fs::remove_file(&path).unwrap();
let output = output.unwrap();
assert!(output.status.success(), "replay failed: {output:?}");
let stdout = String::from_utf8(output.stdout).unwrap();
let id = id.to_string();
let fps: Vec<u32> = stdout
.lines()
.filter_map(|line| {
let fields: Vec<_> = line.split(',').collect();
if fields.len() == 4 && fields[1] == id {
Some(fields[3].parse().unwrap())
} else {
None
}
})
.collect();
assert_eq!(fps.len(), 20, "missing replay samples: {stdout}");
fps
};
let expected = replay(1);
assert_eq!(expected.last(), Some(&30));
assert_eq!(replay(1652), expected);
}
-302
View File
@@ -18,227 +18,6 @@ pub(super) fn session(fps: u32, quality: Quality) -> VideoQoS {
qos
}
fn profiles() -> Vec<(&'static str, Vec<u32>, bool)> {
vec![
("stable_10", vec![10; 120], false),
("stable_80", vec![80; 120], false),
("stable_180", vec![180; 120], false),
("stable_300", vec![300; 120], false),
(
"lan_jitter",
(0..120).map(|i| 10 + (i * 37 % 70)).collect(),
true,
),
(
"isolated_spikes",
(0..120)
.map(|i| if i % 15 == 0 { 800 } else { 10 })
.collect(),
true,
),
("alternating_10_350", [10, 350].repeat(60), true),
(
"two_sample_bursts",
(0..120)
.map(|i| if i % 12 < 2 { 700 } else { 10 })
.collect(),
true,
),
(
"threshold_jitter",
[140, 180, 150, 190, 130, 170].repeat(20),
true,
),
(
"congestion_200_recovery",
[vec![200; 20], vec![10; 60]].concat(),
true,
),
(
"congestion_800_recovery",
[vec![800; 20], vec![10; 60]].concat(),
true,
),
(
"congestion_1500_recovery",
[vec![1500; 20], vec![10; 60]].concat(),
true,
),
(
"rising_then_falling",
(0..80).map(|i| 10 + i.min(79 - i) * 20).collect(),
true,
),
]
}
#[test]
fn smoke_latency_profiles() {
use std::fmt::Write;
let mut csv = String::from("profile,limit,quality,sample,delay_ms,fps\n");
for (quality_name, quality) in [
("balanced", Quality::Balanced),
("best", Quality::Best),
("low", Quality::Low),
] {
for limit in [1, 5, 15, 30, 60, 120] {
for (name, delays, warm_up) in profiles() {
let mut qos = session(limit, quality);
if warm_up {
for _ in 0..90 {
qos.user_network_delay(1, 10);
}
assert_eq!(qos.fps(), limit);
}
let mut trace = Vec::new();
for (i, delay) in delays.into_iter().enumerate() {
qos.user_network_delay(1, delay);
let fps = qos.fps();
assert!((MIN_FPS..=limit).contains(&fps), "{name}: {fps}");
trace.push(fps);
writeln!(csv, "{name},{limit},{quality_name},{i},{delay},{fps}").unwrap();
}
if limit == 30 && quality_name == "balanced" {
println!(
"{name}: first20={:?}, last={}",
&trace[..20],
trace.last().unwrap()
);
}
if name.starts_with("stable_") {
assert_eq!(trace.last(), Some(&limit), "{name}, {quality_name}");
}
if matches!(
name,
"lan_jitter"
| "isolated_spikes"
| "alternating_10_350"
| "two_sample_bursts"
| "threshold_jitter"
) {
assert!(
trace.iter().all(|fps| *fps == limit),
"{name}, {quality_name}"
);
}
if matches!(name, "congestion_800_recovery" | "congestion_1500_recovery") {
assert!(
trace.iter().all(|fps| *fps >= limit.min(5)),
"automatic reductions must preserve the floor: {trace:?}"
);
if limit >= 15 {
assert!(
trace[20] < limit,
"a single good reply must not restore the full frame rate"
);
}
assert_eq!(
trace[21], limit,
"two fresh good replies must restore the frame rate"
);
if name == "congestion_1500_recovery" {
assert_eq!(
trace[5],
limit.min(5),
"severe congestion must brake promptly"
);
}
}
if name == "congestion_200_recovery" && limit >= 15 {
assert!(
trace[..20].iter().min() < Some(&limit),
"moderate sustained congestion must reduce the frame rate: {trace:?}"
);
}
if name.ends_with("_recovery") {
assert_eq!(trace.last(), Some(&limit), "{name}, {quality_name}");
}
}
}
}
if let Ok(path) = std::env::var("RUSTDESK_QOS_SMOKE_CSV") {
std::fs::write(path, csv).unwrap();
}
}
#[test]
fn smoke_bandwidth_drop_and_recovery() {
use std::fmt::Write;
let mut qos = session(30, Quality::Balanced);
for _ in 0..90 {
qos.user_network_delay(1, 10);
}
// Fixed-size frames, FIFO link and one outstanding TestDelay, with a 10 ms base RTT.
// Encoding and ABR are intentionally absent so that FPS alone controls offered load.
let mut queue = 0.0_f64;
let mut probe: Option<(u32, f64, Option<u32>)> = None;
let mut last_delay = 10;
let mut csv = String::from("time_ms,capacity_fps,queue_ms,delay_ms,fps\n");
let mut max_queue_ms = 0;
let mut drained = false;
let mut recovered_at = None;
for now in (0..120_000).step_by(10) {
qos.advance_ms(10);
let capacity = if (10_000..70_000).contains(&now) {
15.0
} else {
40.0
};
queue = (queue + (qos.fps() as f64 - capacity) * 0.01).max(0.0);
if let Some((_, remaining, reply_at)) = probe.as_mut() {
*remaining -= capacity * 0.01;
if *remaining <= 0.0 && reply_at.is_none() {
*reply_at = Some(now + 10);
}
}
if let Some((sent, _, Some(reply_at))) = probe {
if now >= reply_at {
last_delay = now - sent;
qos.user_network_delay(1, last_delay);
probe = None;
}
}
if now % 1000 == 0 {
if probe.is_none() {
probe = Some((now, queue, None));
}
qos.user_delay_response_elapsed(1, (now - probe.unwrap().0) as u128);
let queue_ms = (queue / capacity * 1000.0) as u32;
max_queue_ms = max_queue_ms.max(queue_ms);
if (20_000..40_000).contains(&now) && queue_ms < 100 {
drained = true;
}
if now >= 70_000 && qos.fps() == 30 && recovered_at.is_none() {
recovered_at = Some(now - 70_000);
}
writeln!(
csv,
"{now},{capacity},{queue_ms},{last_delay},{}",
qos.fps()
)
.unwrap();
}
}
println!(
"bandwidth 40 -> 15 -> 40 fps: max_queue_ms={max_queue_ms}, recovery_ms={recovered_at:?}, final_fps={}",
qos.fps()
);
if let Ok(path) = std::env::var("RUSTDESK_QOS_SMOKE_CSV") {
std::fs::write(std::path::Path::new(&path).with_extension("queue.csv"), csv).unwrap();
}
assert!(drained, "congestion must drain after the capacity drop");
assert!(
max_queue_ms < 2500,
"queue must not grow while awaiting confirmation"
);
assert!(
recovered_at.is_some_and(|ms| ms <= 15_000) && qos.fps() == 30,
"FPS must recover when capacity returns"
);
}
#[test]
fn smoke_capacity_with_short_stalls() {
for limit in [30, 60] {
@@ -286,84 +65,3 @@ fn smoke_capacity_with_short_stalls() {
}
}
}
#[test]
fn smoke_abr_bandwidth_drop_and_recovery() {
for reduced_capacity in [27, 24, 15] {
let mut qos = session(30, Quality::Balanced);
for _ in 0..90 {
qos.user_network_delay(1, 10);
}
qos.abr_config = true;
qos.new_display("test".to_owned());
qos.set_support_changing_quality("test", true);
qos.store_bitrate(4000);
let initial_ratio = qos.ratio();
let mut queue = 0.0_f64;
let mut probe: Option<(u32, f64, Option<u32>)> = None;
let mut first_ratio_drop = None;
let mut first_fps_drop = None;
let mut first_fps_drop_delay = None;
let mut last_delay = 10;
let mut max_queue_ms = 0;
let mut drained = false;
let mut recovered_at = None;
for now in (0..120_000).step_by(10) {
qos.advance_ms(10);
let capacity = if (10_000..70_000).contains(&now) {
reduced_capacity as f64
} else {
40.0
};
// Frame size scales with the requested ratio; video and probes share one FIFO.
let frame_size = (qos.ratio() / initial_ratio) as f64;
queue = (queue + (qos.fps() as f64 * frame_size - capacity) * 0.01).max(0.0);
qos.store_bitrate((4000.0 * frame_size) as u32);
if let Some((_, remaining, reply_at)) = probe.as_mut() {
*remaining -= capacity * 0.01;
if *remaining <= 0.0 && reply_at.is_none() {
*reply_at = Some(now + 10);
}
}
if let Some((sent, _, Some(reply_at))) = probe {
if now >= reply_at {
last_delay = now - sent;
qos.user_network_delay(1, last_delay);
probe = None;
}
}
if now % 1000 == 0 {
if probe.is_none() {
probe = Some((now, queue, None));
}
qos.user_delay_response_elapsed(1, (now - probe.unwrap().0) as u128);
qos.update_display_data("test", qos.fps() as usize);
let queue_ms = (queue / capacity * 1000.0) as u32;
max_queue_ms = max_queue_ms.max(queue_ms);
if (20_000..40_000).contains(&now) && queue_ms < 100 {
drained = true;
}
}
if qos.ratio() < initial_ratio && first_ratio_drop.is_none() {
first_ratio_drop = Some(now);
}
if qos.fps() < 30 && first_fps_drop.is_none() {
first_fps_drop = Some(now);
first_fps_drop_delay = Some(last_delay);
}
if now >= 70_000 && qos.fps() == 30 && recovered_at.is_none() {
recovered_at = Some(now - 70_000);
}
}
println!("ABR bandwidth 40 -> {reduced_capacity} -> 40: max_queue_ms={max_queue_ms}, first_ratio_drop_ms={first_ratio_drop:?}, first_fps_drop_ms={first_fps_drop:?}, first_fps_drop_delay_ms={first_fps_drop_delay:?}, recovery_ms={recovered_at:?}, final_fps={}, final_ratio={:.3}", qos.fps(), qos.ratio());
assert!(drained && max_queue_ms < 2500);
assert!(recovered_at.is_some_and(|ms| ms <= 15_000));
assert_eq!(qos.fps(), 30);
assert!(qos.ratio() >= initial_ratio * 0.8);
if reduced_capacity >= 24 {
assert!(first_ratio_drop.is_some_and(|ratio_time| {
first_fps_drop.map_or(true, |fps_time| ratio_time < fps_time)
}));
}
}
}
-132
View File
@@ -63,138 +63,6 @@ fn an_unclean_startup_reply_disables_faster_growth() {
}
}
#[test]
fn a_failed_startup_probe_rolls_back_and_does_not_restart() {
let mut qos = session(120, false);
for _ in 0..2 {
reply(&mut qos, 10);
}
let probe = qos.fps();
assert!(probe >= 30, "fixture must reach the accelerated level");
reply(&mut qos, 1200);
assert!(qos.fps() <= probe / 2, "rollback must remain prompt");
for _ in 0..2 {
reply(&mut qos, 10);
}
assert_eq!(qos.fps(), probe, "keep the existing two-reply recovery");
for _ in 0..8 {
let before = qos.fps();
reply(&mut qos, 10);
assert!(qos.fps() <= before + (before / 5).max(6));
}
}
#[test]
fn a_startup_spike_still_requires_congestion_confirmation() {
let mut qos = session(120, false);
for _ in 0..2 {
reply(&mut qos, 10);
}
let probe = qos.fps();
for _ in 0..2 {
reply(&mut qos, 800);
assert_eq!(qos.fps(), probe, "startup is not a failed fast restore");
}
reply(&mut qos, 800);
assert!(qos.fps() < probe, "three fresh bad replies must reduce FPS");
assert!(qos.fps() >= probe - probe / 5);
}
#[test]
fn a_startup_timeout_keeps_legacy_recovery() {
let mut qos = session(120, false);
qos.advance_ms(3000);
qos.user_delay_response_elapsed(1, 3001);
assert_eq!(qos.fps(), 5);
reply(&mut qos, 3100);
assert_eq!(qos.fps(), 5, "late reply must not undo the brake");
for _ in 0..2 {
reply(&mut qos, 10);
}
assert_eq!(qos.fps(), INIT_FPS);
for _ in 0..8 {
let before = qos.fps();
reply(&mut qos, 10);
assert!(qos.fps() <= before + (before / 5).max(6));
}
}
#[test]
fn raising_the_cap_does_not_restart_startup_acceleration() {
let mut qos = session(30, false);
for _ in 0..6 {
reply(&mut qos, 10);
}
assert_eq!(qos.fps(), 30);
qos.user_custom_fps(1, 120);
for _ in 0..6 {
let before = qos.fps();
reply(&mut qos, 10);
assert!(qos.fps() <= before + (before / 5).max(6));
}
}
#[test]
fn startup_remains_per_viewer_and_preserves_the_join_guard() {
let mut qos = session(120, false);
for _ in 0..20 {
reply(&mut qos, 10);
}
qos.users.insert(
2,
UserData {
joined_at: Some(qos.now()),
custom_fps: Some(30),
..Default::default()
},
);
qos.user_network_delay(2, 10);
assert_eq!(qos.fps(), INIT_FPS);
assert_eq!(qos.users[&1].delay.fps, Some(120));
qos.advance_ms(1000);
qos.user_network_delay(2, 10);
assert_eq!(qos.users[&2].delay.fps, Some(30));
assert_eq!(qos.fps(), 30);
qos.on_connection_close(2);
assert_eq!(qos.fps(), 120);
}
#[test]
fn closed_loop_startup_preserves_quality_and_bounds_queueing() {
use super::sim::{self, EncoderModel};
for cap in [30, 60, 120] {
for base in [10, 150, 300, 600] {
for encoder in [EncoderModel::Cbr, EncoderModel::FixedRate] {
for seed in 1..=5 {
let mut sc = sim::scenarios()
.into_iter()
.find(|sc| sc.name == "city_relay_30")
.unwrap();
sc.seconds = 30;
sc.limit = cap;
sc.link.base_rtt_ms = base as f64;
sc.encoder = encoder;
sc.seed = seed;
let report = sim::run(&sc);
let budget = match cap {
30 => 2000,
60 => 4000,
_ => 6000,
} + base;
assert!(
report.time_to_90pct_ms.is_some_and(|t| t <= budget),
"cap={cap} base={base}: {report:?}"
);
assert_eq!(report.final_fps, cap);
assert_eq!(report.final_ratio, sc.quality.ratio());
assert!(report.trace.iter().all(|(_, _, queue, _)| *queue < 150));
assert!(report.frame_age_p95_ms < 150);
}
}
}
}
}
#[test]
fn constrained_startup_does_not_leave_a_large_queue() {
use super::sim::{self, EncoderModel};
-59
View File
@@ -215,43 +215,6 @@ mod tests {
assert_eq!(m("streams:empty:"), WAYLAND_NO_USABLE_SCREEN);
}
#[test]
fn only_a_portal_that_may_be_dead_is_told_to_restart() {
let m = |tag| staged_message(tag, false);
// Not reached the bus at all: the portal has not been asked anything yet.
assert_eq!(
m("session-bus:dbus:org.freedesktop.DBus.Error.NotSupported"),
WAYLAND_NO_SESSION
);
// Answering, but without an implementation behind the interface that was called --
// at any stage, not just the first one.
assert_eq!(
m("create-session:unsupported:org.freedesktop.DBus.Error.UnknownMethod"),
WAYLAND_UNSUPPORTED
);
assert_eq!(
m("select-sources:unsupported:org.freedesktop.DBus.Error.UnknownMethod"),
WAYLAND_UNSUPPORTED
);
// Absent or silent, which is what the existing key's remedy is for.
assert_eq!(
m("create-session:dbus:org.freedesktop.DBus.Error.ServiceUnknown"),
SCRAP_XDP_PORTAL_UNAVAILABLE
);
// Not this one: `Start` had already been answered, so the portal was alive and the
// request granted. Saying it may have crashed would walk the diagnosis backwards.
assert_eq!(
m("open-pipewire-remote:dbus:org.freedesktop.DBus.Error.Failed"),
WAYLAND_PIPEWIRE_HANDOVER
);
// A tag this build does not know must never fall back to a guess.
assert_eq!(
m("some-new-stage:some-new-kind:x"),
SCRAP_XDP_PORTAL_UNAVAILABLE
);
assert_eq!(m(""), SCRAP_XDP_PORTAL_UNAVAILABLE);
}
// The peer resolves a message by replacing its first `{...}` with `{}` and looking that
// up, so a detail-carrying message has to reduce back to its key exactly.
#[test]
@@ -268,28 +231,6 @@ mod tests {
WAYLAND_GST_UNAVAILABLE
);
}
#[test]
fn legacy_ubuntu_keeps_its_message_for_machine_faults_only() {
let m = |tag| staged_message(tag, true);
assert_eq!(
m("create-session:dbus:org.freedesktop.DBus.Error.ServiceUnknown"),
SCRAP_UBUNTU_HIGHER_REQUIRED
);
assert_eq!(
m("create-session:unsupported:org.freedesktop.DBus.Error.UnknownMethod"),
SCRAP_UBUNTU_HIGHER_REQUIRED
);
assert_eq!(
m("gst-plugin:unavailable:pipewiresrc"),
SCRAP_UBUNTU_HIGHER_REQUIRED
);
assert_eq!(m("streams:empty:"), SCRAP_UBUNTU_HIGHER_REQUIRED);
assert_eq!(m("session-bus:dbus:"), SCRAP_UBUNTU_HIGHER_REQUIRED);
assert_eq!(m("start:declined:"), WAYLAND_DECLINED);
assert_eq!(m("start:ended:"), WAYLAND_ENDED);
assert_eq!(m("start:no-response:"), WAYLAND_TIMED_OUT);
}
}
struct CapturerPtr(*mut Capturer);
-42
View File
@@ -1848,46 +1848,4 @@ mod tests {
}
});
}
/// Tests that symlink creation works on this platform.
/// This is a helper to verify the test environment supports symlinks.
#[test]
#[cfg(not(any(target_os = "ios")))]
fn test_symlink_creation_works() {
let base_dir = std::env::temp_dir().join("rustdesk_symlink_test");
let _ = fs::remove_dir_all(&base_dir);
fs::create_dir_all(&base_dir).unwrap();
// Create target file in a subdirectory
let target_dir = base_dir.join("target_dir");
fs::create_dir_all(&target_dir).unwrap();
let target_file = target_dir.join("target.txt");
fs::write(&target_file, b"content").unwrap();
// Create symlink in a different directory
let link_dir = base_dir.join("link_dir");
fs::create_dir_all(&link_dir).unwrap();
let link_path = link_dir.join("link.txt");
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
if symlink(&target_file, &link_path).is_err() {
let _ = fs::remove_dir_all(&base_dir);
return;
}
}
#[cfg(windows)]
{
use std::os::windows::fs::symlink_file;
if symlink_file(&target_file, &link_path).is_err() {
// Skip if no permission (needs admin or dev mode on Windows)
let _ = fs::remove_dir_all(&base_dir);
return;
}
}
let _ = fs::remove_dir_all(&base_dir);
}
}
+1 -10
View File
@@ -1742,16 +1742,7 @@ pub fn is_remote_modify_enabled_by_control_permissions() -> Option<bool> {
#[cfg(test)]
mod tests {
use super::{trim_video_save_directory, validate_windows_service_video_save_directory};
#[test]
fn trim_configured_video_save_directory() {
assert_eq!(
trim_video_save_directory(" relative/recordings "),
Some("relative/recordings")
);
assert_eq!(trim_video_save_directory(" "), None);
}
use super::validate_windows_service_video_save_directory;
#[test]
fn validate_service_video_save_directory() {
-11
View File
@@ -27,17 +27,6 @@ fn verify_offline_for_package(
)
}
#[test]
fn accepts_valid_signed_offline_artifact() {
let artifact = verify_offline(&valid_fixture(), "rustdesk-1.4.6-x86_64.exe")
.expect("verified offline artifact");
assert_eq!("rustdesk-1.4.6-x86_64.exe", artifact.file_name);
assert_eq!(123456, artifact.size);
assert_eq!(SHA256, artifact.sha256);
assert_eq!("1.4.6", artifact.version);
}
#[test]
fn offline_metadata_requires_the_expected_package_identity() {
const CUSTOM_PACKAGE_ID: &str = "com.example.rustdesk-custom";
-75
View File
@@ -97,25 +97,6 @@ fn verify(fixture: &Fixture) -> ResultType<VerifiedUpdateArtifact> {
verify_with_query(fixture, &query())
}
fn verify_for_package(
fixture: &Fixture,
expected_package_id: &str,
) -> ResultType<VerifiedUpdateArtifact> {
let trusted_keys = [key(fixture.public_key)];
verify_update_metadata_with_options(
&fixture.metadata,
&fixture.signature,
VerificationOptions {
requirements: requirements(query()),
expected_package_id,
artifact_url_policy: ArtifactUrlPolicy::ExactPrefix(
"https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/",
),
trusted_keys: &trusted_keys,
},
)
}
#[test]
fn accepts_valid_metadata_and_signature() {
let artifact = verify(&valid_fixture()).expect("verified artifact");
@@ -125,17 +106,6 @@ fn accepts_valid_metadata_and_signature() {
assert_eq!("1.4.6", artifact.version);
}
#[test]
fn accepts_only_the_expected_package_identity() {
const CUSTOM_PACKAGE_ID: &str = "com.example.rustdesk-custom";
let mut custom_metadata = metadata();
custom_metadata["package_id"] = json!(CUSTOM_PACKAGE_ID);
let fixture = sign_fixture(custom_metadata, KEY_ID);
assert!(verify_for_package(&fixture, CUSTOM_PACKAGE_ID).is_ok());
assert!(verify_for_package(&fixture, "rustdesk").is_err());
}
#[test]
fn rejects_bad_signature_or_tampered_metadata() {
let mut fixture = valid_fixture();
@@ -175,33 +145,6 @@ fn rejects_schema_policy_artifact_and_url_mismatches() {
}
}
#[test]
fn rejects_artifact_url_query_or_fragment() {
for suffix in ["?download=1", "#hash"] {
let mut invalid_metadata = metadata();
invalid_metadata["artifacts"][0]["url"] = json!(format!(
"https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/rustdesk-1.4.6-x86_64.exe{suffix}"
));
let fixture = sign_fixture(invalid_metadata, KEY_ID);
let error = verify(&fixture).err().expect("invalid artifact URL");
assert_eq!(
"update artifact URL must not contain query or fragment",
error.to_string()
);
}
}
#[test]
fn rejects_duplicate_artifact_matches() {
let mut duplicate_metadata = metadata();
duplicate_metadata["artifacts"]
.as_array_mut()
.expect("artifacts")
.push(artifact());
assert!(verify(&sign_fixture(duplicate_metadata, KEY_ID)).is_err());
}
#[test]
fn rejects_artifact_file_name_path_components() {
for file_name in [
@@ -229,24 +172,6 @@ fn rejects_artifact_file_name_path_components() {
}
}
#[test]
fn rejects_path_components_in_unselected_artifact_file_name() {
let mut invalid_metadata = metadata();
let mut unselected_artifact = artifact();
unselected_artifact["platform"] = json!("linux");
unselected_artifact["file_name"] = json!("nested/rustdesk-linux.exe");
invalid_metadata["artifacts"]
.as_array_mut()
.expect("artifacts")
.push(unselected_artifact);
let error = verify(&sign_fixture(invalid_metadata, KEY_ID))
.err()
.expect("invalid file name");
assert_eq!("invalid update artifact file name", error.to_string());
}
#[test]
fn verifies_metadata_generated_by_python_release_script_fixture() {
const PYTHON_KEY_ID: &str = "python-test-ed25519-main";
-14
View File
@@ -242,18 +242,4 @@ mod tests {
assert!(get_update_download_file_from_url(url).is_none(), "{url}");
}
}
#[test]
fn limited_sidecar_reader_rejects_oversized_payloads() {
let mut payload: &[u8] = b"rustdesk";
assert_eq!(
read_limited_response_bytes(&mut payload, 8, "sidecar")
.unwrap()
.len(),
8
);
let mut oversized: &[u8] = b"too-large";
assert!(read_limited_response_bytes(&mut oversized, 4, "sidecar").is_err());
}
}
-19
View File
@@ -31,25 +31,6 @@ fn copy_and_verify_update_artifact_hashes_open_file_handle() {
assert_eq!(position, Some(0));
}
#[test]
fn remove_cached_update_artifact_rejects_directory() {
let test_dir = std::env::temp_dir().join(format!(
"rustdesk-updater-cache-dir-test-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&test_dir);
std::fs::create_dir_all(&test_dir).unwrap();
let cache_path = test_dir.join("rustdesk-update.exe");
std::fs::create_dir(&cache_path).unwrap();
std::fs::write(cache_path.join("stale"), b"stale").unwrap();
let result = remove_cached_update_artifact(&cache_path);
assert!(result.is_err());
assert!(cache_path.exists());
std::fs::remove_dir_all(&test_dir).unwrap();
}
#[cfg(unix)]
#[test]
fn verified_download_replaces_symlink_without_touching_target() {