From 67f63f5ea9c83ccfde6e4f759c4ad85ae9fa1921 Mon Sep 17 00:00:00 2001 From: Michael Krohn Date: Fri, 6 Nov 2026 15:50:57 +0100 Subject: [PATCH 1/2] fix: release adb and scrcpy processes on stop and hangs - ScrcpySession polls sys.boot_completed with short getprop calls instead of one long adb shell loop (held a CommandExec slot for up to 3 min) and stops waiting on stop(). - stop() during start() released only what existed at that moment; the server process and adb forward created afterwards stayed behind. The start now releases them when it returns. - The video worker watched socket.done, which never completes when the server closes the stream; use the read side's onDone. - Kill an adb track-devices process whose subscription was cancelled while it was starting. - 15 s timeout for `adb emu kill` and the gRPC session's `adb emu` calls, which hang on a frozen emulator. - runFfmpeg no longer reruns a failed conversion with another binary. simutil_adb 1.4.1. --- app/CHANGELOG.md | 10 ++ packages/simutil/CHANGELOG.md | 1 + packages/simutil_adb/CHANGELOG.md | 19 +++ .../simutil_adb/lib/src/adb_device_watch.dart | 11 +- .../lib/src/android_device_service.dart | 3 + .../src/emulator/emulator_grpc_session.dart | 9 +- packages/simutil_adb/lib/src/ffmpeg.dart | 14 +- .../lib/src/scrcpy/scrcpy_session.dart | 70 ++++++++-- .../lib/src/scrcpy/scrcpy_video_worker.dart | 42 +++--- packages/simutil_adb/pubspec.yaml | 2 +- .../test/scrcpy/scrcpy_session_test.dart | 127 +++++++++++++++++- 11 files changed, 268 insertions(+), 40 deletions(-) diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index bd20639..2ea7893 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -25,6 +25,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A stream's title wraps to two lines before it is cut. - Settings changed in a device window last until it closes. +### Fixed + +- Closing the stream of an Android device that is still booting no longer + leaves the streaming server running on it. +- A stream shows that it ended when the device stops sending video, + instead of freezing on the last frame. +- Shutting down or recording a frozen Android emulator no longer hangs. +- A failed video conversion after a recording is no longer run a second + time. + ## [0.5.0] - 2026-10-23 ### Added diff --git a/packages/simutil/CHANGELOG.md b/packages/simutil/CHANGELOG.md index 6610ad0..c519641 100644 --- a/packages/simutil/CHANGELOG.md +++ b/packages/simutil/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Shutting down a frozen Android emulator no longer hangs. - Headless Android emulators (`simutil launch --headless`) use your computer's GPU, so they run much faster. ## [1.2.0] - 2026-10-17 diff --git a/packages/simutil_adb/CHANGELOG.md b/packages/simutil_adb/CHANGELOG.md index f15eb6b..ad5b9e0 100644 --- a/packages/simutil_adb/CHANGELOG.md +++ b/packages/simutil_adb/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 1.4.1 + +- `ScrcpySession` waits for `sys.boot_completed` with short `getprop` + polls instead of one `adb shell` loop, so a booting device no longer + holds a `CommandExec` slot for up to 3 min, and `stop()` ends the wait. +- `ScrcpySession.stop()` during `start()` no longer leaves the scrcpy + server, its `adb shell` process or the `adb forward` behind: what the + start creates after `stop()` is released when it returns. +- `ScrcpySession` reports `Stream ended` when the server closes the video + socket; the worker watched `socket.done`, which only completes for the + write side. +- `watchAndroidDevices` kills an `adb track-devices` process whose + subscription was cancelled while it was still starting. +- `AndroidDeviceService.shutdownSimulator` and the `adb emu` calls of + `EmulatorGrpcSession` time out after 15 s instead of waiting forever on + a hung emulator's console. +- `runFfmpeg` tries the next ffmpeg candidate only when one cannot be + started, not again after a failed or timed-out conversion. + ## 1.4.0 - `EmulatorGrpcSession` streams and controls an Android emulator over its diff --git a/packages/simutil_adb/lib/src/adb_device_watch.dart b/packages/simutil_adb/lib/src/adb_device_watch.dart index 2a7c000..e936875 100644 --- a/packages/simutil_adb/lib/src/adb_device_watch.dart +++ b/packages/simutil_adb/lib/src/adb_device_watch.dart @@ -36,6 +36,7 @@ class AdbTrackDevicesDecoder { /// exits. Long-lived, so it uses `Process.start` (not `CommandExec`). Stream _trackDevicesOnce(String adbPath) { Process? process; + var cancelled = false; late final StreamController controller; controller = StreamController( onListen: () async { @@ -43,6 +44,11 @@ Stream _trackDevicesOnce(String adbPath) { final started = process = await Process.start(adbPath, [ 'track-devices', ]); + // Cancelled while starting: onCancel had no process to kill yet. + if (cancelled) { + started.kill(); + return; + } unawaited(started.stdin.close()); unawaited(started.stderr.drain()); final decoder = AdbTrackDevicesDecoder(); @@ -55,7 +61,10 @@ Stream _trackDevicesOnce(String adbPath) { } await controller.close(); }, - onCancel: () => process?.kill(), + onCancel: () { + cancelled = true; + process?.kill(); + }, ); return controller.stream; } diff --git a/packages/simutil_adb/lib/src/android_device_service.dart b/packages/simutil_adb/lib/src/android_device_service.dart index a733d61..22c86ad 100644 --- a/packages/simutil_adb/lib/src/android_device_service.dart +++ b/packages/simutil_adb/lib/src/android_device_service.dart @@ -579,6 +579,9 @@ class AndroidDeviceService implements DeviceService { final result = await _exec.run( adbPath, arguments: ['-s', deviceId, 'emu', 'kill'], + // `adb emu` talks to the emulator console, which a hung emulator + // never answers. + timeout: const Duration(seconds: 15), priority: CommandPriority.interactive, ); return result.success; diff --git a/packages/simutil_adb/lib/src/emulator/emulator_grpc_session.dart b/packages/simutil_adb/lib/src/emulator/emulator_grpc_session.dart index 53f8b12..5c1b2d6 100644 --- a/packages/simutil_adb/lib/src/emulator/emulator_grpc_session.dart +++ b/packages/simutil_adb/lib/src/emulator/emulator_grpc_session.dart @@ -263,7 +263,11 @@ class _EmulatorGrpcSession implements EmulatorGrpcSession { final path = _recordingPath; _recordingPath = null; if (path == null) return null; - await _emu(['screenrecord', 'stop']); + try { + await _emu(['screenrecord', 'stop']); + } catch (_) { + // A hung console: convert whatever was written. + } final converted = await runFfmpeg(_exec, [ '-y', '-loglevel', @@ -282,9 +286,12 @@ class _EmulatorGrpcSession implements EmulatorGrpcSession { return path; } + /// `adb emu` waits on the emulator console, which a hung emulator never + /// answers. Future _emu(List args) => _exec.run( adbPath, arguments: ['-s', serial, 'emu', ...args], + timeout: const Duration(seconds: 15), priority: CommandPriority.interactive, ); diff --git a/packages/simutil_adb/lib/src/ffmpeg.dart b/packages/simutil_adb/lib/src/ffmpeg.dart index b3bcb76..aad4dd8 100644 --- a/packages/simutil_adb/lib/src/ffmpeg.dart +++ b/packages/simutil_adb/lib/src/ffmpeg.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:simutil_core/simutil_core.dart'; @@ -16,6 +17,10 @@ final h264EncoderArgs = Platform.isMacOS : const ['-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23']; /// Runs ffmpeg with [arguments]; false when it is missing or failed. +/// +/// Tries the next candidate only when one cannot be started: once an +/// ffmpeg ran, its failure or timeout is the answer (a second binary would +/// redo the same conversion). Future runFfmpeg( CommandExec exec, List arguments, { @@ -28,9 +33,12 @@ Future runFfmpeg( arguments: arguments, timeout: timeout, ); - if (result.success) return true; - } catch (_) { - continue; + return result.success; + } catch (e) { + // Through an isolate the error arrives as its text. + if (e is TimeoutException || '$e'.contains('TimeoutException')) { + return false; + } } } return false; diff --git a/packages/simutil_adb/lib/src/scrcpy/scrcpy_session.dart b/packages/simutil_adb/lib/src/scrcpy/scrcpy_session.dart index 0793077..55689db 100644 --- a/packages/simutil_adb/lib/src/scrcpy/scrcpy_session.dart +++ b/packages/simutil_adb/lib/src/scrcpy/scrcpy_session.dart @@ -187,24 +187,49 @@ class _ScrcpySession implements ScrcpySession { _emit(const SessionConnecting()); try { await _startServer(); - await _connect(); + if (!_stopped) await _connect(); } catch (e) { // Emit before stop(): it closes the status stream. _emit(SessionFailed('$e')); await stop(); + } finally { + // stop() during the start only released what existed by then. + if (_stopped) await _release(); + } + } + + /// Polls `sys.boot_completed` with short commands, so a booting device + /// does not hold a [CommandExec] slot for minutes and [stop] ends the + /// wait. + Future _waitForBoot() async { + final deadline = DateTime.now().add(_bootTimeout); + while (!_stopped) { + final boot = await _adb([ + 'shell', + 'getprop', + 'sys.boot_completed', + ], timeout: _bootPollTimeout); + if (!boot.success) { + throw StateError( + 'device did not finish booting: ${boot.stderr.trim()}', + ); + } + if (boot.stdout.trim() == '1') return; + if (DateTime.now().isAfter(deadline)) { + throw StateError( + 'device did not finish booting within ' + '${_bootTimeout.inMinutes} min', + ); + } + await Future.delayed(_bootPollInterval); } } Future _startServer() async { // adb lists an emulator while Android is still booting; the server needs // system services (its cleanup thread crashes on a null ActivityManager). - final boot = await _adb([ - 'shell', - 'while [ "\$(getprop sys.boot_completed)" != 1 ]; do sleep 1; done', - ], timeout: _bootTimeout); - if (!boot.success) { - throw StateError('device did not finish booting: ${boot.stderr.trim()}'); - } + await _waitForBoot(); + if (_stopped) return; final push = await _adb(['push', install.serverPath, _remoteJar]); if (!push.success) { throw StateError('adb push failed: ${push.stderr.trim()}'); @@ -221,6 +246,7 @@ class _ScrcpySession implements ScrcpySession { if (!forward.success || _forwardPort == null) { throw StateError('adb forward failed: ${forward.stderr.trim()}'); } + if (_stopped) return; _server = await Process.start(adbPath, [ '-s', @@ -280,6 +306,7 @@ class _ScrcpySession implements ScrcpySession { _controlSocket = await Socket.connect(InternetAddress.loopbackIPv4, _forwardPort!) ..setOption(SocketOption.tcpNoDelay, true); + if (_stopped) return; unawaited(_controlSocket!.drain()); if (turnScreenOff) _controlSocket!.add(ScrcpyProtocol.displayPower(false)); _emitLive(); @@ -395,19 +422,38 @@ class _ScrcpySession implements ScrcpySession { await stopRecording(); _stopped = true; _keyFrameTimer?.cancel(); + await _release(); + await _frames.close(); + await _statusController.close(); + } + + /// Ends the worker, sockets, server and forward that exist so far; safe + /// to call again for ones a still-running [start] created after [stop]. + Future _release() async { _video?.stop(); + _video = null; _controlSocket?.destroy(); + _controlSocket = null; _server?.kill(); - if (_forwardPort case final port?) { - await _adb(['forward', '--remove', 'tcp:$port']); + _server = null; + final port = _forwardPort; + _forwardPort = null; + if (port != null) { + try { + await _adb(['forward', '--remove', 'tcp:$port']); + } catch (_) {} } - await _frames.close(); - await _statusController.close(); } /// A cold boot can take minutes after adb first sees the emulator. static const _bootTimeout = Duration(minutes: 3); + /// One `getprop` while waiting for the boot. + static const _bootPollTimeout = Duration(seconds: 10); + + /// Pause between boot checks. + static const _bootPollInterval = Duration(seconds: 1); + Future _adb( List args, { Duration timeout = const Duration(seconds: 30), diff --git a/packages/simutil_adb/lib/src/scrcpy/scrcpy_video_worker.dart b/packages/simutil_adb/lib/src/scrcpy/scrcpy_video_worker.dart index b13d26a..b89f7b3 100644 --- a/packages/simutil_adb/lib/src/scrcpy/scrcpy_video_worker.dart +++ b/packages/simutil_adb/lib/src/scrcpy/scrcpy_video_worker.dart @@ -109,27 +109,32 @@ class ScrcpyVideoWorker { static Future _main((SendPort, int) args) async { final (events, port) = args; final video = _Video(events); - final socket = await _connect(port, video.add); - if (socket == null) { + final connected = await _connect(port, video.add); + if (connected == null) { events.send(StateError('scrcpy server did not start')); return; } + final (socket, ended) = connected; final commands = ReceivePort(); + var closed = false; Future close() async { + if (closed) return; + closed = true; commands.close(); socket.destroy(); video.freeNative(); await video.record(null); } + // The read side ending, not `socket.done`: that only completes for + // the write side and misses the server closing the stream. unawaited( - socket.done.then((_) {}, onError: (Object _) {}).whenComplete( - () async { - events.send(_ended); - await close(); - }, - ), + ended.then((_) async { + if (closed) return; + events.send(_ended); + await close(); + }), ); events.send(commands.sendPort); await for (final command in commands) { @@ -154,8 +159,9 @@ class ScrcpyVideoWorker { } /// The forward tunnel accepts connections before the server listens, so - /// retry until the dummy byte arrives; later bytes go to [onData]. - static Future _connect( + /// retry until the dummy byte arrives; later bytes go to [onData]. Returns + /// the socket and a future that completes when its read side ends. + static Future<(Socket, Future)?> _connect( int port, void Function(List) onData, ) async { @@ -168,21 +174,23 @@ class ScrcpyVideoWorker { continue; } final firstByte = Completer(); + final ended = Completer(); + void end() { + if (!firstByte.isCompleted) firstByte.complete(false); + if (!ended.isCompleted) ended.complete(); + } + socket.listen( (bytes) { if (firstByte.isCompleted) return onData(bytes); firstByte.complete(true); if (bytes.length > 1) onData(bytes.sublist(1)); }, - onDone: () { - if (!firstByte.isCompleted) firstByte.complete(false); - }, - onError: (Object _) { - if (!firstByte.isCompleted) firstByte.complete(false); - }, + onDone: end, + onError: (Object _) => end(), cancelOnError: true, ); - if (await firstByte.future) return socket; + if (await firstByte.future) return (socket, ended.future); socket.destroy(); await Future.delayed(const Duration(milliseconds: 100)); } diff --git a/packages/simutil_adb/pubspec.yaml b/packages/simutil_adb/pubspec.yaml index 5cc6c25..9373799 100644 --- a/packages/simutil_adb/pubspec.yaml +++ b/packages/simutil_adb/pubspec.yaml @@ -1,6 +1,6 @@ name: simutil_adb description: Android emulator and device APIs for SimUtil via adb. -version: 1.4.0 +version: 1.4.1 homepage: https://github.com/dungngminh/simutil repository: https://github.com/dungngminh/simutil/tree/main/packages/simutil_adb issue_tracker: https://github.com/dungngminh/simutil/issues diff --git a/packages/simutil_adb/test/scrcpy/scrcpy_session_test.dart b/packages/simutil_adb/test/scrcpy/scrcpy_session_test.dart index 9b47a5c..df7ce3f 100644 --- a/packages/simutil_adb/test/scrcpy/scrcpy_session_test.dart +++ b/packages/simutil_adb/test/scrcpy/scrcpy_session_test.dart @@ -41,11 +41,7 @@ void main() { // Process.start target for the server; `true` exits at once. adbPath: 'true', install: const ScrcpyInstall(serverPath: 'server', version: '3.0'), - exec: FakeCommandExec( - (_, args) => args.contains('forward') && !args.contains('--remove') - ? FakeCommandExec.ok('${server.port}\n') - : FakeCommandExec.ok(), - ), + exec: _bootedExec(server.port), ); addTearDown(session.stop); final live = session.statusChanges.firstWhere((s) => s is SessionLive); @@ -78,6 +74,127 @@ void main() { video.destroy(); control.destroy(); }); + + test('reports the server closing the video stream', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(server.close); + final connections = StreamIterator(server); + final session = ScrcpySession( + serial: 'emulator-5554', + adbPath: 'true', + install: const ScrcpyInstall(serverPath: 'server', version: '3.0'), + exec: _bootedExec(server.port), + ); + addTearDown(session.stop); + final statuses = session.statusChanges.toList(); + + final starting = session.start(); + expect(await connections.moveNext(), isTrue); + final video = connections.current + ..add([0]) + ..add(_u32(0x68323634)) + ..add([..._u32(0x80000000), ..._u32(720), ..._u32(1280)]); + expect(await connections.moveNext(), isTrue); + final control = connections.current; + await starting; + + await video.close(); + await Future.delayed(const Duration(milliseconds: 300)); + control.destroy(); + await session.stop(); + + expect(await statuses, contains(const SessionFailed('Stream ended'))); + }); + + test('stop() while waiting for the boot ends the wait and starts no ' + 'server', () async { + final exec = FakeCommandExec( + (_, args) => args.contains('getprop') + ? FakeCommandExec.ok('0\n') + : FakeCommandExec.ok('1234\n'), + ); + final session = ScrcpySession( + serial: 'emulator-5554', + adbPath: 'true', + install: const ScrcpyInstall(serverPath: 'server', version: '3.0'), + exec: exec, + ); + + final starting = session.start(); + await Future.delayed(const Duration(milliseconds: 50)); + await session.stop(); + await starting.timeout(const Duration(seconds: 3)); + + expect( + exec.calls.where( + (c) => c.arguments.contains('push') || c.arguments.contains('forward'), + ), + isEmpty, + ); + }); + + test('stop() during the start removes a forward created after it', () async { + final forwardRequested = Completer(); + final releaseForward = Completer(); + final exec = _GatedExec((args) async { + if (args.contains('getprop')) return FakeCommandExec.ok('1\n'); + if (args.contains('forward') && !args.contains('--remove')) { + forwardRequested.complete(); + await releaseForward.future; + return FakeCommandExec.ok('4321\n'); + } + return FakeCommandExec.ok(); + }); + final session = ScrcpySession( + serial: 'emulator-5554', + adbPath: 'true', + install: const ScrcpyInstall(serverPath: 'server', version: '3.0'), + exec: exec, + ); + + final starting = session.start(); + await forwardRequested.future; + await session.stop(); + releaseForward.complete(); + await starting; + + expect( + exec.calls, + contains( + equals(['-s', 'emulator-5554', 'forward', '--remove', 'tcp:4321']), + ), + ); + }); +} + +/// adb reports the device booted and forwards to [port]. +FakeCommandExec _bootedExec(int port) => FakeCommandExec( + (_, args) => switch (args) { + _ when args.contains('getprop') => FakeCommandExec.ok('1\n'), + _ when args.contains('forward') && !args.contains('--remove') => + FakeCommandExec.ok('$port\n'), + _ => FakeCommandExec.ok(), + }, +); + +/// A [CommandExec] whose results the test can hold back. +class _GatedExec implements CommandExec { + _GatedExec(this.handler); + + final Future Function(List args) handler; + final calls = >[]; + + @override + Future run( + String command, { + List arguments = const [], + String? workingDirectory, + Duration? timeout, + CommandPriority priority = CommandPriority.normal, + }) { + calls.add(arguments); + return handler(arguments); + } } List _u32(int v) => From 3073165997e05446121497954ac152e83e4186ac Mon Sep 17 00:00:00 2001 From: Michael Krohn Date: Wed, 11 Nov 2026 09:04:03 +0100 Subject: [PATCH 2/2] docs(app): move changelog entries under Unreleased --- app/CHANGELOG.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 2ea7893..9df12b6 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Closing the stream of an Android device that is still booting no longer + leaves the streaming server running on it. +- A stream shows that it ended when the device stops sending video, + instead of freezing on the last frame. +- Shutting down or recording a frozen Android emulator no longer hangs. +- A failed video conversion after a recording is no longer run a second + time. + ## [0.6.0] - 2026-11-11 ### Added @@ -25,16 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A stream's title wraps to two lines before it is cut. - Settings changed in a device window last until it closes. -### Fixed - -- Closing the stream of an Android device that is still booting no longer - leaves the streaming server running on it. -- A stream shows that it ended when the device stops sending video, - instead of freezing on the last frame. -- Shutting down or recording a frozen Android emulator no longer hangs. -- A failed video conversion after a recording is no longer run a second - time. - ## [0.5.0] - 2026-10-23 ### Added