mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-10-19 16:17:03 +00:00
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:
co-authored by
Claude Opus 5.5
parent
76ab280ae9
commit
9997b7a3b8
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,17 +126,17 @@ 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}) {
|
||||
final image = img.Image(width: size.$1, height: size.$2, numChannels: 4);
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¤t_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,
|
||||
¤t_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
@@ -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(¤t).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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+7
-1112
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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 |");
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user