fix: adb logcat with invalid utf8 crashes shorebird preview (#3460)

This commit is contained in:
Eric Seidel
2026-01-07 16:12:01 -08:00
committed by GitHub
parent a60def12fb
commit 312e4e26b6
8 changed files with 173 additions and 17 deletions
@@ -17,7 +17,6 @@ import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/logging/shorebird_logger.dart';
import 'package:shorebird_cli/src/metadata/metadata.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart';
import 'package:shorebird_cli/src/platform/apple/plist.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_cli/src/shorebird_artifacts.dart';
@@ -394,10 +394,7 @@ This is only applicable when previewing Android releases.''',
await process.run('chmod', ['+x', executableFile.path]);
final proc = await process.start(executableFile.path, []);
proc.stdout.listen((log) => logger.info(utf8.decode(log)));
proc.stderr.listen((log) => logger.err(utf8.decode(log)));
return proc.exitCode;
return startAndForwardOutput(executableFile.path);
}
/// Downloads and runs the given [release] the given [appId] on Windows.
@@ -463,10 +460,7 @@ This is only applicable when previewing Android releases.''',
(file) => file.path.endsWith('.exe'),
);
final proc = await process.start(exeFile.path, []);
proc.stdout.listen((log) => logger.info(utf8.decode(log)));
proc.stderr.listen((log) => logger.err(utf8.decode(log)));
return proc.exitCode;
return startAndForwardOutput(exeFile.path);
}
/// Installs and launches the release on macOS.
@@ -553,8 +547,12 @@ This is only applicable when previewing Android releases.''',
return line.trim().replaceFirst(prefixRegex, '').trim();
}
// TODO(eseidel): Use startAndForwardOutput instead?
// This doesn't seem to handle stderr, maybe it should?
// Use allowMalformed to handle non-UTF8 bytes in log stream output.
const decoder = Utf8Decoder(allowMalformed: true);
logs.listen((log) {
final logLine = utf8.decode(log);
final logLine = decoder.convert(log);
if (logFilters.any((filter) => filter.hasMatch(logLine))) {
return;
}
@@ -711,11 +709,14 @@ This is only applicable when previewing Android releases.''',
}
final process = await adb.logcat(filter: 'flutter', deviceId: deviceId);
// adb logcat sometimes lets non-utf8 characters through, so we need to
// not crash when it does.
const decoder = Utf8Decoder(allowMalformed: true);
process.stdout.listen((event) {
logger.info(utf8.decode(event));
logger.info(decoder.convert(event));
});
process.stderr.listen((event) {
logger.err(utf8.decode(event));
logger.err(decoder.convert(event));
});
return process.exitCode;
@@ -1018,6 +1019,18 @@ This is only applicable when previewing Android releases.''',
shorebirdYamlFile.writeAsStringSync(yamlEditor.toString(), flush: true);
return true;
}
/// Starts a process and forwards its stdout/stderr to the logger.
///
/// Returns the process exit code.
Future<int> startAndForwardOutput(String executable) async {
final proc = await process.start(executable, []);
// Use allowMalformed to handle non-UTF8 bytes in process output.
const decoder = Utf8Decoder(allowMalformed: true);
proc.stdout.listen((log) => logger.info(decoder.convert(log)));
proc.stderr.listen((log) => logger.err(decoder.convert(log)));
return proc.exitCode;
}
}
/// Extension on [Release] that exposes the active platforms (e.g. platforms
@@ -14,7 +14,6 @@ import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/metadata/metadata.dart';
import 'package:shorebird_cli/src/platform/apple/apple.dart';
import 'package:shorebird_cli/src/platform/apple/plist.dart';
import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
@@ -13,7 +13,6 @@ import 'package:shorebird_cli/src/executables/xcodebuild.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/logging/shorebird_logger.dart';
import 'package:shorebird_cli/src/metadata/update_release_metadata.dart';
import 'package:shorebird_cli/src/platform/apple/plist.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
@@ -214,6 +214,7 @@ class AotTools {
return version >= Version(0, 0, 1);
}
/// Returns the version of the aot_tools executable.
@visibleForTesting
Future<Version> getVersion() async {
// Use 0.0.0 to allow callers to easily compare w/o checking for null.
@@ -88,12 +88,14 @@ class IDeviceSysLog {
environment: {'DYLD_LIBRARY_PATH': _dyldPathEntry},
);
// Use allowMalformed to handle non-UTF8 bytes in device syslog output.
const decoder = Utf8Decoder(allowMalformed: true);
loggerProcess.stdout
.transform<String>(utf8.decoder)
.transform<String>(decoder)
.transform<String>(const LineSplitter())
.listen(_parseLogLine);
loggerProcess.stderr
.transform<String>(utf8.decoder)
.transform<String>(decoder)
.transform<String>(const LineSplitter())
.listen(_parseLogLine);
return loggerProcess.exitCode;
@@ -1,4 +1,4 @@
// cspell:words libinfo networkd
// cspell:words libinfo networkd FFFD
import 'dart:async';
import 'dart:convert';
import 'dart:io' hide Platform;
@@ -1031,6 +1031,36 @@ channel: ${track.channel}
verify(() => logger.err(output)).called(1);
});
test('handles non-UTF8 bytes in output without throwing', () async {
when(
() => artifactManager.extractZip(
zipFile: any(named: 'zipFile'),
outputDirectory: any(named: 'outputDirectory'),
),
).thenAnswer(setupAndroidShorebirdYaml);
final completer = Completer<int>();
when(() => process.exitCode).thenAnswer((_) => completer.future);
// Create byte sequences with invalid UTF-8: 0xFF is not valid in UTF-8
// "Hello" + 0xFF + "!" for stdout, "Error" + 0xFF + "!" for stderr
when(
() => process.stdout,
).thenAnswer(
(_) => Stream.value([0x48, 0x65, 0x6C, 0x6C, 0x6F, 0xFF, 0x21]),
);
when(
() => process.stderr,
).thenAnswer(
(_) => Stream.value([0x45, 0x72, 0x72, 0x6F, 0x72, 0xFF, 0x21]),
);
final result = runWithOverrides(command.run);
completer.complete(0);
await expectLater(await result, equals(ExitCode.success.code));
// The invalid bytes should be replaced with the replacement character
verify(() => logger.info('Hello\uFFFD!')).called(1);
verify(() => logger.err('Error\uFFFD!')).called(1);
});
group('when in a shorebird project without flavors', () {
setUp(() {
when(
@@ -2022,6 +2052,35 @@ channel: ${DeploymentTrack.staging.channel}
verify(() => logger.err('hello error')).called(1);
});
});
test('handles non-UTF8 bytes in output without throwing', () async {
setupPreviewArtifact(
baseDirectory: Directory(
p.join(
previewDirectory.path,
'linux_${releaseVersion}_$releaseArtifactId',
),
),
);
// Create byte sequences with invalid UTF-8: 0xFF is not valid in UTF-8
// "Hello" + 0xFF + "!" for stdout, "Error" + 0xFF + "!" for stderr
when(
() => process.stdout,
).thenAnswer(
(_) => Stream.value([0x48, 0x65, 0x6C, 0x6C, 0x6F, 0xFF, 0x21]),
);
when(
() => process.stderr,
).thenAnswer(
(_) => Stream.value([0x45, 0x72, 0x72, 0x6F, 0x72, 0xFF, 0x21]),
);
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.success.code));
// The invalid bytes should be replaced with the replacement character
verify(() => logger.info('Hello\uFFFD!')).called(1);
verify(() => logger.err('Error\uFFFD!')).called(1);
});
});
group('macos', () {
@@ -2243,6 +2302,30 @@ channel: ${DeploymentTrack.staging.channel}
);
});
});
group('non-UTF8 handling', () {
setUp(setupMacosShorebirdYaml);
test('handles non-UTF8 bytes in log stream without throwing', () async {
// Create bytes with invalid UTF-8: 0xFF is not valid in UTF-8
// Simulates a macOS log line with invalid bytes
final invalidUtf8Bytes = [
...utf8.encode(
'2025-01-31 09:53:22.889 Df app[22535:1d268] [shorebird] Hello',
),
0xFF,
0x0A, // newline
];
when(() => open.newApplication(path: any(named: 'path'))).thenAnswer(
(_) async => Stream.value(invalidUtf8Bytes),
);
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.success.code));
// The invalid byte should be replaced with the replacement character
verify(() => logger.info('[shorebird] Hello\uFFFD')).called(1);
});
});
});
group('windows', () {
@@ -2462,6 +2545,31 @@ channel: ${DeploymentTrack.staging.channel}
verify(() => logger.err('hello error')).called(1);
});
});
test('handles non-UTF8 bytes in output without throwing', () async {
File(
p.join(windowsReleaseDirectory.path, 'runner.exe'),
).createSync(recursive: true);
createShorebirdYaml();
// Create byte sequences with invalid UTF-8: 0xFF is not valid in UTF-8
// "Hello" + 0xFF + "!" for stdout, "Error" + 0xFF + "!" for stderr
when(
() => process.stdout,
).thenAnswer(
(_) => Stream.value([0x48, 0x65, 0x6C, 0x6C, 0x6F, 0xFF, 0x21]),
);
when(
() => process.stderr,
).thenAnswer(
(_) => Stream.value([0x45, 0x72, 0x72, 0x6F, 0x72, 0xFF, 0x21]),
);
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.success.code));
// The invalid bytes should be replaced with the replacement character
verify(() => logger.info('Hello\uFFFD!')).called(1);
verify(() => logger.err('Error\uFFFD!')).called(1);
});
});
group('when no platform is specified', () {
@@ -1,3 +1,4 @@
// cspell:words FFFD
import 'dart:convert';
import 'dart:io';
@@ -149,6 +150,40 @@ Nov 10 17:58:47 kernel(Sandbox)[0] <Error>: Sandbox: Runner(52662) deny(1) iokit
verify(() => logger.info('flutter: hello from stderr')).called(1);
},
);
test('handles non-UTF8 bytes in output without throwing', () async {
// Create byte sequences with invalid UTF-8: 0xFF is not valid in UTF-8
// "flutter: hello" + 0xFF for stdout, "flutter: error" + 0xFF for
// stderr
final stdoutBytes = [
...utf8.encode(
'Nov 10 14:46:57 Runner(Flutter)[1044] <Notice>: flutter: hello',
),
0xFF,
0x0A, // newline
];
final stderrBytes = [
...utf8.encode(
'Nov 10 14:46:57 Runner(Flutter)[1044] <Notice>: flutter: error',
),
0xFF,
0x0A, // newline
];
when(
() => loggerProcess.stdout,
).thenAnswer((_) => Stream.value(stdoutBytes));
when(
() => loggerProcess.stderr,
).thenAnswer((_) => Stream.value(stderrBytes));
await runWithOverrides(
() => idevicesyslog.startLogger(device: device),
);
// The invalid bytes should be replaced with the replacement character
verify(() => logger.info('flutter: hello\uFFFD')).called(1);
verify(() => logger.info('flutter: error\uFFFD')).called(1);
});
});
});
}