diff --git a/Xamarin.Android.sln b/Xamarin.Android.sln index 480b25a4ca3..8dcbe7a5396 100644 --- a/Xamarin.Android.sln +++ b/Xamarin.Android.sln @@ -156,6 +156,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Java.Runtime.Environment", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "create-android-api", "build-tools\create-android-api\create-android-api.csproj", "{BA4D889D-066B-4C2C-A973-09E319CBC396}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "debug-session-prep", "tools\debug-session-prep\debug-session-prep.csproj", "{087C42C4-6B45-4020-AB39-52515265082E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "xadebug", "tools\xadebug\xadebug.csproj", "{F4D105FA-D394-437A-B3BA-0EC56C6734C3}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Xamarin.Android.Tools.Aidl-Tests", "tests\Xamarin.Android.Tools.Aidl-Tests\Xamarin.Android.Tools.Aidl-Tests.csproj", "{A39B6D7C-6616-40D6-8AE4-C6CEE93D2708}" EndProject Global @@ -300,12 +304,10 @@ Global {B8105878-D423-4159-A3E7-028298281EC6}.Debug|AnyCPU.Build.0 = Debug|Any CPU {B8105878-D423-4159-A3E7-028298281EC6}.Release|AnyCPU.ActiveCfg = Release|Any CPU {B8105878-D423-4159-A3E7-028298281EC6}.Release|AnyCPU.Build.0 = Release|Any CPU - {43564FB3-0F79-4FF4-A2B0-B1637072FF01}.Debug|AnyCPU.ActiveCfg = Debug|Any CPU {43564FB3-0F79-4FF4-A2B0-B1637072FF01}.Debug|AnyCPU.Build.0 = Debug|Any CPU {43564FB3-0F79-4FF4-A2B0-B1637072FF01}.Release|AnyCPU.ActiveCfg = Release|Any CPU {43564FB3-0F79-4FF4-A2B0-B1637072FF01}.Release|AnyCPU.Build.0 = Release|Any CPU - {3DE17662-DCD6-4F49-AF06-D39AACC8649A}.Debug|AnyCPU.ActiveCfg = Debug|Any CPU {3DE17662-DCD6-4F49-AF06-D39AACC8649A}.Debug|AnyCPU.Build.0 = Debug|Any CPU {3DE17662-DCD6-4F49-AF06-D39AACC8649A}.Release|AnyCPU.ActiveCfg = Release|Any CPU @@ -418,6 +420,10 @@ Global {D8E14B43-E929-4C18-9FA6-2C3DC47EFC17}.Debug|AnyCPU.Build.0 = Debug|Any CPU {D8E14B43-E929-4C18-9FA6-2C3DC47EFC17}.Release|AnyCPU.ActiveCfg = Release|Any CPU {D8E14B43-E929-4C18-9FA6-2C3DC47EFC17}.Release|AnyCPU.Build.0 = Release|Any CPU + {087C42C4-6B45-4020-AB39-52515265082E}.Debug|AnyCPU.ActiveCfg = Debug|Any CPU + {087C42C4-6B45-4020-AB39-52515265082E}.Debug|AnyCPU.Build.0 = Debug|Any CPU + {087C42C4-6B45-4020-AB39-52515265082E}.Release|AnyCPU.ActiveCfg = Release|Any CPU + {087C42C4-6B45-4020-AB39-52515265082E}.Release|AnyCPU.Build.0 = Release|Any CPU {C0E44558-FEE3-4DD3-986A-3F46DD1BF41B}.Debug|AnyCPU.ActiveCfg = Debug|Any CPU {C0E44558-FEE3-4DD3-986A-3F46DD1BF41B}.Debug|AnyCPU.Build.0 = Debug|Any CPU {C0E44558-FEE3-4DD3-986A-3F46DD1BF41B}.Release|AnyCPU.ActiveCfg = Release|Any CPU @@ -426,6 +432,10 @@ Global {BA4D889D-066B-4C2C-A973-09E319CBC396}.Debug|AnyCPU.Build.0 = Debug|Any CPU {BA4D889D-066B-4C2C-A973-09E319CBC396}.Release|AnyCPU.ActiveCfg = Release|Any CPU {BA4D889D-066B-4C2C-A973-09E319CBC396}.Release|AnyCPU.Build.0 = Release|Any CPU + {F4D105FA-D394-437A-B3BA-0EC56C6734C3}.Debug|AnyCPU.ActiveCfg = Debug|Any CPU + {F4D105FA-D394-437A-B3BA-0EC56C6734C3}.Debug|AnyCPU.Build.0 = Debug|Any CPU + {F4D105FA-D394-437A-B3BA-0EC56C6734C3}.Release|AnyCPU.ActiveCfg = Release|Any CPU + {F4D105FA-D394-437A-B3BA-0EC56C6734C3}.Release|AnyCPU.Build.0 = Release|Any CPU {A39B6D7C-6616-40D6-8AE4-C6CEE93D2708}.Debug|AnyCPU.ActiveCfg = Debug|Any CPU {A39B6D7C-6616-40D6-8AE4-C6CEE93D2708}.Debug|AnyCPU.Build.0 = Debug|Any CPU {A39B6D7C-6616-40D6-8AE4-C6CEE93D2708}.Release|AnyCPU.ActiveCfg = Release|Any CPU @@ -499,8 +509,10 @@ Global {DA50FC92-7FE7-48B5-BDB6-CDA57B37BB51} = {864062D3-A415-4A6F-9324-5820237BA058} {4EFCED6E-9A6B-453A-94E4-CE4B736EC684} = {864062D3-A415-4A6F-9324-5820237BA058} {D8E14B43-E929-4C18-9FA6-2C3DC47EFC17} = {864062D3-A415-4A6F-9324-5820237BA058} + {087C42C4-6B45-4020-AB39-52515265082E} = {864062D3-A415-4A6F-9324-5820237BA058} {C0E44558-FEE3-4DD3-986A-3F46DD1BF41B} = {04E3E11E-B47D-4599-8AFC-50515A95E715} {BA4D889D-066B-4C2C-A973-09E319CBC396} = {E351F97D-EA4F-4E7F-AAA0-8EBB1F2A4A62} + {F4D105FA-D394-437A-B3BA-0EC56C6734C3} = {864062D3-A415-4A6F-9324-5820237BA058} {A39B6D7C-6616-40D6-8AE4-C6CEE93D2708} = {CAB438D8-B0F5-4AF0-BEBD-9E2ADBD7B483} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/build-tools/create-packs/Microsoft.Android.Sdk.proj b/build-tools/create-packs/Microsoft.Android.Sdk.proj index 7deb4a8975a..957a493d033 100644 --- a/build-tools/create-packs/Microsoft.Android.Sdk.proj +++ b/build-tools/create-packs/Microsoft.Android.Sdk.proj @@ -51,6 +51,10 @@ core workload SDK packs imported by WorkloadManifest.targets. <_PackageFiles Include="$(MicrosoftAndroidSdkOutDir)class-parse.dll" PackagePath="tools" /> <_PackageFiles Include="$(MicrosoftAndroidSdkOutDir)class-parse.pdb" PackagePath="tools" /> <_PackageFiles Include="$(MicrosoftAndroidSdkOutDir)class-parse.runtimeconfig.json" PackagePath="tools" /> + <_PackageFiles Include="$(MicrosoftAndroidSdkOutDir)debug-session-prep.deps.json" PackagePath="tools" /> + <_PackageFiles Include="$(MicrosoftAndroidSdkOutDir)debug-session-prep.dll" PackagePath="tools" /> + <_PackageFiles Include="$(MicrosoftAndroidSdkOutDir)debug-session-prep.pdb" PackagePath="tools" /> + <_PackageFiles Include="$(MicrosoftAndroidSdkOutDir)debug-session-prep.runtimeconfig.json" PackagePath="tools" /> <_PackageFiles Include="$(MicrosoftAndroidSdkOutDir)generator.dll" PackagePath="tools" /> <_PackageFiles Include="$(MicrosoftAndroidSdkOutDir)generator.pdb" PackagePath="tools" /> <_PackageFiles Include="$(MicrosoftAndroidSdkOutDir)generator.runtimeconfig.json" PackagePath="tools" /> diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.NativeProfilingAndDebugging.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.NativeProfilingAndDebugging.targets new file mode 100644 index 00000000000..71c41d8d788 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.NativeProfilingAndDebugging.targets @@ -0,0 +1,88 @@ + + + + + + + + + <_AndroidEnableNativeCodeProfiling>False + <_AndroidEnableNativeDebugging>False + + + _ResolveMonoAndroidSdks; + _SetupNativeCodeProfiling; + Build; + Install; + + + + _ResolveMonoAndroidSdks; + _SetupNativeCodeDebugging; + Build; + Install; + + + + + + <_AndroidEnableNativeCodeProfiling>True + <_AndroidAotStripLibraries>False + <_AndroidStripNativeLibraries>False + + + apk + + + + + + + + + + + + + + + + + + + <_AndroidEnableNativeDebugging>True + <_AndroidAotStripLibraries>False + <_AndroidStripNativeLibraries>False + + + + + + + + + + + diff --git a/src/Xamarin.Android.Build.Tasks/Resources/debug-app.ps1 b/src/Xamarin.Android.Build.Tasks/Resources/debug-app.ps1 new file mode 100644 index 00000000000..b12418a1581 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Resources/debug-app.ps1 @@ -0,0 +1,9 @@ +# +# How to read vars from a file +# +# $vars = Get-Content -Raw ./settings.txt | ConvertFrom-StringData +# +# Access to them: +# +# $vars.one +# write-host "$($vars.one)" diff --git a/src/Xamarin.Android.Build.Tasks/Resources/debug-app.sh b/src/Xamarin.Android.Build.Tasks/Resources/debug-app.sh new file mode 100755 index 00000000000..fcc0b4e1ea2 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Resources/debug-app.sh @@ -0,0 +1,154 @@ +#!/bin/bash +MY_DIR="$(cd $(dirname $0);pwd)" + +ADB_PATH="@ADB_PATH@" +APP_LIBS_DIR="@APP_LIBS_DIR@" +CONFIG_SCRIPT_NAME="@CONFIG_SCRIPT_NAME@" +DEBUG_SESSION_PREP_PATH="@DEBUG_SESSION_PREP_PATH@" +DEFAULT_ACTIVITY_NAME="@ACTIVITY_NAME@" +LLDB_SCRIPT_NAME="@LLDB_SCRIPT_NAME@" +NDK_DIR="@NDK_DIR@" +OUTPUT_DIR="@OUTPUT_DIR@" +PACKAGE_NAME="@PACKAGE_NAME@" +SUPPORTED_ABIS_ARRAY=(@SUPPORTED_ABIS@) +ADB_DEVICE="" + +function die() +{ + echo "$@" + exit 1 +} + +function die_if_failed() +{ + if [ $? -ne 0 ]; then + die "$@" + fi +} + +function run_adb_no_echo() +{ + local args="" + + if [ -n "${ADB_DEVICE}" ]; then + args="-s ${ADB_DEVICE}" + fi + + adb ${args} "$@" +} + +function run_adb() +{ + echo Running: adb ${args} "$@" + run_adb_no_echo "$@" +} + +function run_adb_echo_info() +{ + local log_file="$1" + shift + + echo Running: adb ${args} "$@" + echo Logging to: "${log_file}" + echo +} + +function run_adb_with_log() +{ + local log_file="$1" + shift + + run_adb_echo_info "${log_file}" "$@" + run_adb_no_echo "$@" > "${log_file}" 2>&1 +} + +function run_adb_with_log_bg() +{ + local log_file="$1" + shift + + run_adb_echo_info "${log_file}" "$@" + run_adb_no_echo "$@" > "${log_file}" 2>&1 & +} + +#TODO: APP_LIBS_DIR needs to be appended the abi-specific subdir +#TODO: make NDK_DIR overridable via a parameter +#TOOD: add a parameter to specify the Android device to target +#TODO: detect whether we have dotnet in $PATH and whether it's a compatible version +#TODO: add an option to make XA wait for debugger to connect +#TODO: add a parameter to specify activity to start + +ACTIVITY_NAME="${DEFAULT_ACTIVITY_NAME}" + +SUPPORTED_ABIS_ARG="" +for sa in "${SUPPORTED_ABIS_ARRAY[@]}"; do + if [ -z "${SUPPORTED_ABIS_ARG}" ]; then + SUPPORTED_ABIS_ARG="${sa}" + else + SUPPORTED_ABIS_ARG="${SUPPORTED_ABIS_ARG},${sa}" + fi +done + +dotnet "${DEBUG_SESSION_PREP_PATH}" \ + -s "${SUPPORTED_ABIS_ARG}" \ + -p "${PACKAGE_NAME}" \ + -n "${NDK_DIR}" \ + -o "${OUTPUT_DIR}" \ + -l "${APP_LIBS_DIR}" \ + -c "${CONFIG_SCRIPT_NAME}" \ + -g "${LLDB_SCRIPT_NAME}" + +die_if_failed Debug preparation app failed + +CONFIG_SCRIPT_PATH="${OUTPUT_DIR}/${CONFIG_SCRIPT_NAME}" +if [ ! -f "${CONFIG_SCRIPT_PATH}" ]; then + die Config script ${CONFIG_SCRIPT_PATH} not found +fi + +source "${CONFIG_SCRIPT_PATH}" + +# Determine cross section of supported and device ABIs +ALLOWED_ABIS=() + +for dabi in "${DEVICE_AVAILABLE_ABIS[@]}"; do + for sabi in "${SUPPORTED_ABIS_ARRAY[@]}"; do + if [ "${dabi}" == "${sabi}" ]; then + ALLOWED_ABIS+="${dabi}" + fi + done +done + +if [ ${#ALLOWED_ABIS[@]} -le 0 ]; then + die Application does not support any ABIs available on device +fi + +ADB_DEBUG_SERVER_LOG="${OUTPUT_DIR}/lldb-debug-server.log" +echo Starting debug server on device +echo stdout and stderr will be redirected to: ${ADB_DEBUG_SERVER_LOG} + +LLDB_SERVER_PLATFORM_LOG="${DEVICE_LLDB_DIR}/log/platform.log" +LLDB_SERVER_STDOUT_LOG="${DEVICE_LLDB_DIR}/log/platform-stdout.log" +LLDB_SERVER_GDB_LOG="${DEVICE_LLDB_DIR}/log/gdb-server.log" + +run_adb shell run-as ${PACKAGE_NAME} kill -9 "\"\`pidof ${DEVICE_DEBUG_SERVER_LAUNCHER}\`\"" +run_adb_with_log_bg "${ADB_DEBUG_SERVER_LOG}" shell run-as ${PACKAGE_NAME} ${DEVICE_DEBUG_SERVER_LAUNCHER} ${DEVICE_LLDB_DIR} ${SOCKET_SCHEME} ${SOCKET_DIR} ${SOCKET_NAME} "\"lldb process:gdb-remote packets\"" + +LAUNCH_SPEC=${PACKAGE_NAME}/${ACTIVITY_NAME} +run_adb shell am start -S -W ${LAUNCH_SPEC} +die_if_failed Failed to start ${LAUNCH_SPEC} + +APP_PID=$(run_adb_no_echo shell pidof ${PACKAGE_NAME}) +die_if_failed Failed to get ${PACKAGE_NAME} PID on device + +LLDB_SCRIPT_PATH="${OUTPUT_DIR}/${LLDB_SCRIPT_NAME}" +echo App PID: ${APP_PID} +echo "attach ${APP_PID}" >> "${LLDB_SCRIPT_PATH}" + +#TODO: start the app if not running +#TODO: pass app pid to lldb, with -p or --attach-pid +export TERMINFO=/usr/share/terminfo +"${LLDB_PATH}" --source "${LLDB_SCRIPT_PATH}" + +run_adb_with_log "${OUTPUT_DIR}/lldb-platform.log" shell run-as ${PACKAGE_NAME} cat ${LLDB_SERVER_PLATFORM_LOG} +run_adb_with_log "${OUTPUT_DIR}/lldb-platform-stdout.log" shell run-as ${PACKAGE_NAME} cat ${LLDB_SERVER_STDOUT_LOG} +run_adb_with_log "${OUTPUT_DIR}/lldb-gdb-server.log" shell run-as ${PACKAGE_NAME} cat ${LLDB_SERVER_GDB_LOG} diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BuildApk.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BuildApk.cs index 3b9b763d3af..95c2971af66 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BuildApk.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BuildApk.cs @@ -95,6 +95,10 @@ public class BuildApk : AndroidTask public bool UseAssemblyStore { get; set; } + public bool NativeCodeProfilingEnabled { get; set; } + + public string DeviceSdkVersion { get; set; } + [Required] public string ProjectFullPath { get; set; } @@ -192,6 +196,7 @@ void ExecuteWithAbi (string [] supportedAbis, string apkInputPath, string apkOut } AddRuntimeLibraries (apk, supportedAbis); + AddProfilingScripts (apk, supportedAbis); apk.Flush(); AddNativeLibraries (files, supportedAbis); AddAdditionalNativeLibraries (files, supportedAbis); @@ -621,9 +626,11 @@ CompressionMethod GetCompressionMethod (string fileName) return CompressionMethod.Default; } + string GetArchiveAbiLibPath (string abi, string fileName) => $"lib/{abi}/{fileName}"; + void AddNativeLibraryToArchive (ZipArchiveEx apk, string abi, string filesystemPath, string inArchiveFileName) { - string archivePath = $"lib/{abi}/{inArchiveFileName}"; + string archivePath = GetArchiveAbiLibPath (abi, inArchiveFileName); existingEntries.Remove (archivePath); CompressionMethod compressionMethod = GetCompressionMethod (archivePath); if (apk.SkipExistingFile (filesystemPath, archivePath, compressionMethod)) { @@ -634,6 +641,26 @@ void AddNativeLibraryToArchive (ZipArchiveEx apk, string abi, string filesystemP apk.Archive.AddEntry (archivePath, File.OpenRead (filesystemPath), compressionMethod); } + void AddProfilingScripts (ZipArchiveEx apk, string [] supportedAbis) + { + if (!NativeCodeProfilingEnabled || !Int32.TryParse (DeviceSdkVersion, out int sdkVersion) || sdkVersion < 26) { + // wrap.sh is available on Android O (API 26) or newer + return; + } + + string wrapScript = "#!/system/bin/sh\n$@\n"; + byte[] wrapScriptBytes = new UTF8Encoding (false).GetBytes (wrapScript); + EntryPermissions wrapScriptPermissions = + EntryPermissions.WorldRead | EntryPermissions.WorldExecute | + EntryPermissions.GroupRead | EntryPermissions.GroupExecute | + EntryPermissions.OwnerRead | EntryPermissions.OwnerWrite | EntryPermissions.OwnerExecute; + + foreach (var abi in supportedAbis) { + string path = GetArchiveAbiLibPath (abi, "wrap.sh"); + apk.Archive.AddEntry (wrapScriptBytes, path, wrapScriptPermissions, CompressionMethod.Default); + } + } + void AddRuntimeLibraries (ZipArchiveEx apk, string [] supportedAbis) { foreach (var abi in supportedAbis) { @@ -807,7 +834,7 @@ private void AddAdditionalNativeLibraries (ArchiveFileList files, string [] supp void AddNativeLibrary (ArchiveFileList files, string path, string abi, string archiveFileName) { string fileName = string.IsNullOrEmpty (archiveFileName) ? Path.GetFileName (path) : archiveFileName; - var item = (filePath: path, archivePath: $"lib/{abi}/{fileName}"); + var item = (filePath: path, archivePath: GetArchiveAbiLibPath (abi, fileName)); if (files.Any (x => x.archivePath == item.archivePath)) { Log.LogCodedWarning ("XA4301", path, 0, Properties.Resources.XA4301, item.archivePath); return; diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/CreateTypeManagerJava.cs b/src/Xamarin.Android.Build.Tasks/Tasks/CreateTypeManagerJava.cs index 37872f31927..6b7fa4ac594 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/CreateTypeManagerJava.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/CreateTypeManagerJava.cs @@ -1,5 +1,4 @@ using System.IO; -using System.Reflection; using System.Text; using System; @@ -18,11 +17,9 @@ public class CreateTypeManagerJava : AndroidTask [Required] public string OutputFilePath { get; set; } - static readonly Assembly ExecutingAssembly = Assembly.GetExecutingAssembly (); - public override bool RunTask () { - string? content = ReadResource (ResourceName); + string? content = MonoAndroidHelper.ReadManifestResource (Log, ResourceName); if (String.IsNullOrEmpty (content)) { return false; @@ -61,19 +58,5 @@ public override bool RunTask () return !Log.HasLoggedErrors; } - - string? ReadResource (string resourceName) - { - using (var from = ExecutingAssembly.GetManifestResourceStream (resourceName)) { - if (from == null) { - Log.LogCodedError ("XA0116", Properties.Resources.XA0116, resourceName); - return null; - } - - using (var sr = new StreamReader (from)) { - return sr.ReadToEnd (); - } - } - } } } diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/DebugNativeCode.cs b/src/Xamarin.Android.Build.Tasks/Tasks/DebugNativeCode.cs new file mode 100644 index 00000000000..bc8bdb1c89d --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tasks/DebugNativeCode.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; + +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Framework; + +namespace Xamarin.Android.Tasks +{ + public class DebugNativeCode : AndroidAsyncTask + { + public override string TaskPrefix => "DNC"; + + [Required] + public string AndroidNdkPath { get; set; } + + [Required] + public string PackageName { get; set; } + + [Required] + public string[] SupportedAbis { get; set; } + + [Required] + public string AdbPath { get; set; } + + [Required] + public string MainActivityName { get; set; } + + [Required] + public string IntermediateOutputDir { get; set; } + + [Required] + public ITaskItem[] NativeLibraries { get; set; } + + public string ActivityName { get; set; } + public string TargetDeviceName { get; set; } + + public async override System.Threading.Tasks.Task RunTaskAsync () + { + var nativeLibs = new Dictionary> (StringComparer.OrdinalIgnoreCase); + foreach (ITaskItem item in NativeLibraries) { + string? abi = null; + string? rid = item.GetMetadata ("RuntimeIdentifier"); + + if (!String.IsNullOrEmpty (rid)) { + abi = NdkHelper.RIDToABI (rid); + } + + if (String.IsNullOrEmpty (abi)) { + abi = item.GetMetadata ("abi"); + } + + if (String.IsNullOrEmpty (abi)) { + Log.LogDebugMessage ($"Ignoring native library {item.ItemSpec} because it doesn't specify its ABI"); + continue; + } + + if (!nativeLibs.TryGetValue (abi, out List abiLibs)) { + abiLibs = new List (); + nativeLibs.Add (abi, abiLibs); + } + abiLibs.Add (item.ItemSpec); + } + + var prep = new NativeDebugPrep (Log); + prep.Prepare ( + AdbPath, + AndroidNdkPath, + String.IsNullOrEmpty (ActivityName) ? MainActivityName : ActivityName, + IntermediateOutputDir, + PackageName, + SupportedAbis, + nativeLibs, + TargetDeviceName + ); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateJavaStubs.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateJavaStubs.cs index e1c36a308d8..633d3246db2 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateJavaStubs.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateJavaStubs.cs @@ -92,6 +92,11 @@ public class GenerateJavaStubs : AndroidTask public ITaskItem[] Environments { get; set; } + public bool NativeCodeProfilingEnabled { get; set; } + public string DeviceSdkVersion { get; set; } + + public bool NativeDebuggingEnabled { get; set; } + [Output] public string [] GeneratedBinaryTypeMaps { get; set; } @@ -358,11 +363,22 @@ void Run (DirectoryAssemblyResolver res, bool useMarshalMethods) } manifest.Assemblies.AddRange (userAssemblies.Values); - if (!String.IsNullOrWhiteSpace (CheckedBuild)) { + bool checkedBuild = !String.IsNullOrWhiteSpace (CheckedBuild); + if (NativeCodeProfilingEnabled || NativeDebuggingEnabled || checkedBuild) { // We don't validate CheckedBuild value here, this will be done in BuildApk. We just know that if it's // on then we need android:debuggable=true and android:extractNativeLibs=true + // + // For profiling we only need android:debuggable=true + // manifest.ForceDebuggable = true; - manifest.ForceExtractNativeLibs = true; + if (checkedBuild || NativeDebuggingEnabled) { + manifest.ForceExtractNativeLibs = true; + } + + // is supported on Android Q (API 29) or newer + if (NativeCodeProfilingEnabled && Int32.TryParse (DeviceSdkVersion, out int sdkVersion) && sdkVersion >= 29) { + manifest.Profileable = true; + } } var additionalProviders = manifest.Merge (Log, cache, allJavaTypes, ApplicationJavaClass, EmbedAssemblies, BundledWearApplicationName, MergedManifestDocuments); diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/LinkApplicationSharedLibraries.cs b/src/Xamarin.Android.Build.Tasks/Tasks/LinkApplicationSharedLibraries.cs index bbe49073e8c..fec20276ae2 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/LinkApplicationSharedLibraries.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/LinkApplicationSharedLibraries.cs @@ -38,7 +38,7 @@ sealed class InputFiles public ITaskItem[] ApplicationSharedLibraries { get; set; } [Required] - public bool DebugBuild { get; set; } + public bool KeepDebugInfo { get; set; } [Required] public string AndroidBinUtilsDirectory { get; set; } @@ -132,7 +132,7 @@ IEnumerable GetLinkerConfigs () "--warn-shared-textrel " + "--fatal-warnings"; - string stripSymbolsArg = DebugBuild ? String.Empty : " -s"; + string stripSymbolsArg = KeepDebugInfo ? String.Empty : " -s"; string ld = Path.Combine (AndroidBinUtilsDirectory, MonoAndroidHelper.GetExecutablePath (AndroidBinUtilsDirectory, "ld")); var targetLinkerArgs = new List (); diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/ProfileNativeCode.cs b/src/Xamarin.Android.Build.Tasks/Tasks/ProfileNativeCode.cs new file mode 100644 index 00000000000..eb3d9142f85 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tasks/ProfileNativeCode.cs @@ -0,0 +1,111 @@ +using System; +using System.IO; + +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Framework; +using Xamarin.Android.Tools; + +namespace Xamarin.Android.Tasks +{ + public class ProfileNativeCode : AndroidAsyncTask + { + const string PythonName = "python3"; + + public override string TaskPrefix => "PNC"; + + string[]? pathExt; + + public string DeviceSdkVersion { get; set; } + public bool DeviceIsEmulator { get; set; } + public string[] DeviceSupportedAbis { get; set; } + public string DevicePrimaryABI { get; set; } + + [Required] + public string SimplePerfDirectory { get; set; } + public string NdkPythonDirectory { get; set; } + + public async override System.Threading.Tasks.Task RunTaskAsync () + { + string? pythonPath = null; + + if (!String.IsNullOrEmpty (NdkPythonDirectory)) { + pythonPath = Path.Combine (NdkPythonDirectory, "bin", PythonName); + if (!File.Exists (pythonPath)) { + pythonPath = null; + } + } + + if (String.IsNullOrEmpty (pythonPath)) { + Log.LogWarning ($"NDK {PythonName} not found, will attempt to find one in a system location"); + pythonPath = FindPython (); + if (String.IsNullOrEmpty (pythonPath)) { + Log.LogWarning ($"System {PythonName} not found, will attempt to use executable name without path"); + pythonPath = PythonName; + } + } + + string? appProfilerScript = Path.Combine (SimplePerfDirectory, "app_profiler.py"); + if (!File.Exists (appProfilerScript)) { + Log.LogError ($"Profiling script {appProfilerScript} not found"); + return; + } + + // TODO: prepare a directory with unstripped native libraries (for use with the profiler's -lib argument) + Console.WriteLine ($"python3 path: {pythonPath}"); + Console.WriteLine ($"profiler script path: {appProfilerScript}"); + + var python = new PythonRunner (Log, pythonPath); + + // TODO: params + bool success = await python.RunScript (appProfilerScript); + } + + string? FindPython () + { + // TODO: might be a good idea to try to look for `python` and check its version, if python3 isn't found + if (OS.IsWindows) { + string? envvar = Environment.GetEnvironmentVariable ("PATHEXT"); + if (String.IsNullOrEmpty (envvar)) { + pathExt = new string[] { ".exe", ".bat", ".cmd" }; + } else { + pathExt = envvar.Split (Path.PathSeparator); + } + } + + string? pathVar = Environment.GetEnvironmentVariable ("PATH")?.Trim (); + if (String.IsNullOrEmpty (pathVar)) { + return null; + } + + foreach (string dir in pathVar.Split (Path.PathSeparator)) { + string? exe = GetExecutablePath (dir, PythonName); + if (!String.IsNullOrEmpty (exe)) { + return exe; + } + } + + return null; + } + + string? GetExecutablePath (string dir, string baseExeName) + { + string exePath = Path.Combine (dir, baseExeName); + if (!OS.IsWindows) { + if (File.Exists (exePath)) { + return exePath; + } + + return null; + } + + foreach (string ext in pathExt) { + string exe = $"{exePath}{ext}"; + if (File.Exists (exe)) { + return exe; + } + } + + return null; + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/SetupNativeCodeProfiling.cs b/src/Xamarin.Android.Build.Tasks/Tasks/SetupNativeCodeProfiling.cs new file mode 100644 index 00000000000..da01314b91d --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tasks/SetupNativeCodeProfiling.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; + +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Framework; + +namespace Xamarin.Android.Tasks +{ + public class SetupNativeCodeProfiling : AndroidAsyncTask + { + public override string TaskPrefix => "SNCP"; + + [Required] + public string AdbPath { get; set; } + + [Required] + public string AndroidNdkPath { get; set; } + + public string TargetDeviceName { get; set; } + + [Output] + public string DeviceSdkVersion { get; set; } + + [Output] + public bool DeviceIsEmulator { get; set; } + + [Output] + public string[] DeviceSupportedAbis { get; set; } + + [Output] + public string DevicePrimaryABI { get; set; } + + [Output] + public string SimplePerfDirectory { get; set; } + + [Output] + public string NdkPythonDirectory { get; set; } + + public async override System.Threading.Tasks.Task RunTaskAsync () + { + var adi = new AndroidDeviceInfo (Log, AdbPath, TargetDeviceName); + await adi.Detect (); + + DeviceSdkVersion = adi.DeviceSdkVersion ?? String.Empty; + DeviceIsEmulator = adi.DeviceIsEmulator; + DeviceSupportedAbis = adi.DeviceSupportedAbis ?? new string[] {}; + DevicePrimaryABI = adi.DevicePrimaryABI ?? String.Empty; + + string simplePerfPath = Path.Combine (AndroidNdkPath, "simpleperf"); + if (Directory.Exists (simplePerfPath)) { + SimplePerfDirectory = simplePerfPath; + } else { + Log.LogError ($"Simpleperf directory '{simplePerfPath}' not found"); + } + + string ndkPythonPath = Path.Combine (AndroidNdkPath, "toolchains", "llvm", "prebuilt", NdkHelper.ToolchainHostName, "python3"); + if (Directory.Exists (ndkPythonPath)) { + NdkPythonDirectory = ndkPythonPath; + } else { + Log.LogWarning ($"NDK Python 3 directory '{ndkPythonPath}' does not exist"); + } + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/AdbRunner.cs b/src/Xamarin.Android.Build.Tasks/Utilities/AdbRunner.cs new file mode 100644 index 00000000000..02e7cbd3da2 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/AdbRunner.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +#if NO_MSBUILD +using LoggerType = Xamarin.Android.Utilities.XamarinLoggingHelper; +#else // def NO_MSBUILD +using LoggerType = Microsoft.Build.Utilities.TaskLoggingHelper; +#endif // ndef NO_MSBUILD + +namespace Xamarin.Android.Tasks +{ + class AdbRunner : ToolRunner + { + public delegate bool OutputLineFilter (bool isStdErr, string line); + + class AdbOutputSink : ToolOutputSink + { + bool isStdError; + OutputLineFilter? lineFilter; + + public Action? LineCallback { get; set; } + + public AdbOutputSink (LoggerType logger, bool isStdError, OutputLineFilter? lineFilter) + : base (logger) + { + this.isStdError = isStdError; + this.lineFilter = lineFilter; + + LogLinePrefix = "adb"; + } + + public override void WriteLine (string? value) + { + if (lineFilter != null && lineFilter (isStdError, value ?? String.Empty)) { + return; + } + + base.WriteLine (value); + LineCallback?.Invoke (isStdError, value ?? String.Empty); + } + } + + class AdbStdErrorWrapper : ProcessStandardStreamWrapper + { + OutputLineFilter? lineFilter; + + public AdbStdErrorWrapper (LoggerType logger, OutputLineFilter? lineFilter) + : base (logger) + { + this.lineFilter = lineFilter; + } + + protected override string? PreprocessMessage (string? message, out bool ignoreLine) + { + if (lineFilter == null) { + return base.PreprocessMessage (message, out ignoreLine); + } + + ignoreLine = lineFilter (isStdErr: true, line: message ?? String.Empty); + return message; + } + } + + string[]? initialParams; + + public int ExitCode { get; private set; } + + public AdbRunner (LoggerType logger, string adbPath, string? deviceSerial = null) + : base (logger, adbPath) + { + if (!String.IsNullOrEmpty (deviceSerial)) { + initialParams = new string[] { "-s", deviceSerial }; + } + } + + public async Task<(bool success, string output)> Forward (string local, string remote) + { + var runner = CreateAdbRunner (); + runner.AddArgument ("forward"); + runner.AddArgument (local); + runner.AddArgument (remote); + + return await CaptureAdbOutput (runner); + } + + public async Task Pull (string remotePath, string localPath) + { + var runner = CreateAdbRunner (); + runner.AddArgument ("pull"); + runner.AddArgument (remotePath); + runner.AddArgument (localPath); + + return await RunAdb (runner); + } + + public async Task Push (string localPath, string remotePath) + { + var runner = CreateAdbRunner (); + runner.AddArgument ("push"); + runner.AddArgument (localPath); + runner.AddArgument (remotePath); + + return await RunAdb (runner); + } + + public async Task Install (string apkPath, bool apkIsDebuggable = false, bool replaceExisting = true, bool noStreaming = true) + { + var runner = CreateAdbRunner (); + runner.AddArgument ("install"); + + if (replaceExisting) { + runner.AddArgument ("-r"); + } + + if (apkIsDebuggable) { + runner.AddArgument ("-d"); // Allow version code downgrade + } + + if (noStreaming) { + runner.AddArgument ("--no-streaming"); + } + + runner.AddQuotedArgument (apkPath); + + return await RunAdb (runner); + } + + public async Task<(bool success, string output)> CreateDirectoryAs (string packageName, string directoryPath) + { + return await RunAs (packageName, "mkdir", "-p", directoryPath); + } + + public async Task<(bool success, string output)> RunAs (string packageName, string command, params string[] args) + { + if (String.IsNullOrEmpty (packageName)) { + throw new ArgumentException ("must not be null or empty", nameof (packageName)); + } + + var shellArgs = new List { + packageName, + command, + }; + + if (args != null && args.Length > 0) { + shellArgs.AddRange (args); + } + + return await Shell ("run-as", (IEnumerable)shellArgs, lineFilter: null); + } + + public async Task<(bool success, string output)> GetAppDataDirectory (string packageName) + { + return await RunAs (packageName, "/system/bin/sh", "-c", "pwd"); + } + + public async Task<(bool success, string output)> Shell (string command, List args, OutputLineFilter? lineFilter = null) + { + return await Shell (command, (IEnumerable)args, lineFilter: null); + } + + public async Task<(bool success, string output)> Shell (string command, params string[] args) + { + return await Shell (command, (IEnumerable)args, lineFilter: null); + } + + public async Task<(bool success, string output)> Shell (OutputLineFilter lineFilter, string command, params string[] args) + { + return await Shell (command, (IEnumerable)args, lineFilter); + } + + async Task<(bool success, string output)> Shell (string command, IEnumerable? args, OutputLineFilter? lineFilter) + { + if (String.IsNullOrEmpty (command)) { + throw new ArgumentException ("must not be null or empty", nameof (command)); + } + + var runner = CreateAdbRunner (); + + runner.AddArgument ("shell"); + runner.AddArgument (command); + if (args != null) { + foreach (string arg in args) { + runner.AddArgument (arg); + } + } + + return await CaptureAdbOutput (runner, lineFilter); + } + + public async Task<(bool success, string output)> GetPropertyValue (string propertyName) + { + var runner = CreateAdbRunner (); + return await GetPropertyValue (runner, propertyName); + } + + async Task<(bool success, string output)> GetPropertyValue (ProcessRunner runner, string propertyName) + { + runner.ClearArguments (); + runner.ClearOutputSinks (); + return await Shell ("getprop", propertyName); + } + + async Task<(bool success, string output)> CaptureAdbOutput (ProcessRunner runner, OutputLineFilter? lineFilter = null, bool firstLineOnly = false) + { + string? outputLine = null; + List? lines = null; + + using AdbOutputSink? stderrSink = lineFilter != null ? new AdbOutputSink (Logger, isStdError: true, lineFilter: lineFilter) : null; + using var stdoutSink = new AdbOutputSink (Logger, isStdError: false, lineFilter: lineFilter); + + SetupOutputSinks (runner, stdoutSink, stderrSink, ignoreStderr: stderrSink == null); + stdoutSink.LineCallback = (bool isStdErr, string line) => { + if (firstLineOnly) { + if (outputLine != null) { + return; + } + outputLine = line.Trim (); + return; + } + + if (lines == null) { + lines = new List (); + } + lines.Add (line.Trim ()); + }; + + ProcessStandardStreamWrapper? origStderrWrapper = runner.StandardErrorEchoWrapper; + using AdbStdErrorWrapper? stderrWrapper = lineFilter != null ? new AdbStdErrorWrapper (Logger, lineFilter) : null; + + try { + runner.StandardErrorEchoWrapper = stderrWrapper; + if (!await RunAdb (runner, setupOutputSink: false)) { + return (false, FormatOutputWithLines (lines)); + } + } finally { + runner.StandardErrorEchoWrapper = origStderrWrapper; + } + + if (firstLineOnly) { + return (true, outputLine ?? String.Empty); + } + + return (true, FormatOutputWithLines (lines)); + + string FormatOutputWithLines (List? lines) => lines != null ? String.Join (Environment.NewLine, lines) : String.Empty; + } + + async Task RunAdb (ProcessRunner runner, bool setupOutputSink = true, bool ignoreStderr = true) + { + return await RunTool ( + () => { + TextWriter? stdoutSink = null; + TextWriter? stderrSink = null; + if (setupOutputSink) { + stdoutSink = new AdbOutputSink (Logger, isStdError: false, lineFilter: null); + if (!ignoreStderr) { + stderrSink = new AdbOutputSink (Logger, isStdError: true, lineFilter: null); + } + + SetupOutputSinks (runner, stdoutSink, stderrSink, ignoreStderr); + } + + try { + bool ret = runner.Run (); + ExitCode = runner.ExitCode; + return ret; + } catch { + ExitCode = -0xDEAD; + throw; + } finally { + stdoutSink?.Dispose (); + stderrSink?.Dispose (); + } + } + ); + } + + ProcessRunner CreateAdbRunner () + { + ProcessRunner ret = CreateProcessRunner (initialParams); + + // Let's make sure all the messages we get are in English, since we need to parse some of them to detect problems + ret.Environment["LANG"] = "C"; + return ret; + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/AndroidDeviceInfo.cs b/src/Xamarin.Android.Build.Tasks/Utilities/AndroidDeviceInfo.cs new file mode 100644 index 00000000000..3feb6c8417d --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/AndroidDeviceInfo.cs @@ -0,0 +1,124 @@ +using System; +using System.Threading.Tasks; + +using Microsoft.Build.Utilities; + +using TPLTask = System.Threading.Tasks.Task; + +namespace Xamarin.Android.Tasks +{ + class AndroidDeviceInfo + { + string? targetDevice; + TaskLoggingHelper log; + string adbPath; + + public string? DeviceSdkVersion { get; private set; } + public bool DeviceIsEmulator { get; private set; } + public string[]? DeviceSupportedAbis { get; private set; } + public string? DevicePrimaryABI { get; private set; } + + public AndroidDeviceInfo (TaskLoggingHelper logger, string adbPath, string? targetDevice = null) + { + this.targetDevice = targetDevice; + this.log = logger; + this.adbPath = adbPath; + } + + public async TPLTask Detect () + { + var adb = new AdbRunner (log, adbPath, targetDevice); + + DeviceSdkVersion = await GetProperty (adb, "ro.build.version.sdk", "Android SDK version"); + DevicePrimaryABI = await GetProperty (adb, "ro.product.cpu.abi", "primary ABI"); + + string abis = await GetProperty (adb, "ro.product.cpu.abilist", "ABI list"); + DeviceSupportedAbis = abis?.Split (','); + + string? fingerprint = await GetProperty (adb, "ro.build.fingerprint", "fingerprint"); + if (CheckProperty (fingerprint, (string v) => v.StartsWith ("generic", StringComparison.Ordinal))) { + DeviceIsEmulator = true; + return; + } + + string? model = await GetProperty (adb, "ro.product.model", "product model"); + if (!String.IsNullOrEmpty (model)) { + if (Contains (model, "google_sdk") || + Contains (model, "droid4x", StringComparison.OrdinalIgnoreCase) || + Contains (model, "Emulator") || + Contains (model, "Android SDK built for x86", StringComparison.OrdinalIgnoreCase) + ) { + DeviceIsEmulator = true; + return; + } + } + + string? manufacturer = await GetProperty (adb, "ro.product.manufacturer", "product manufacturer"); + if (CheckProperty (manufacturer, (string v) => Contains (v, "Genymotion", StringComparison.OrdinalIgnoreCase))) { + DeviceIsEmulator = true; + return; + } + + string? hardware = await GetProperty (adb, "ro.hardware", "hardware model"); + if (!String.IsNullOrEmpty (hardware)) { + if (Contains (hardware, "goldfish") || + Contains (hardware, "ranchu") || + Contains (hardware, "vbox86") + ) { + DeviceIsEmulator = true; + return; + } + } + + string? product = await GetProperty (adb, "ro.product.name", "product name"); + if (!String.IsNullOrEmpty (product)) { + if (Contains (product, "sdk_google") || + Contains (product, "google_sdk") || + Contains (product, "sdk") || + Contains (product, "sdk_x86") || + Contains (product, "sdk_gphone64_arm64") || + Contains (product, "vbox86p") || + Contains (product, "emulator") || + Contains (product, "simulator") + ) { + DeviceIsEmulator = true; + return; + } + } + + bool Contains (string s, string sub, StringComparison comparison = StringComparison.Ordinal) + { +#if NETCOREAPP + return s.Contains (sub, comparison); +#else + return s.IndexOf (sub, comparison) >= 0; +#endif + } + + bool CheckProperty (string? value, Func checker) + { + if (String.IsNullOrEmpty (value)) { + return false; + } + + return checker (value); + } + } + + async Task GetProperty (AdbRunner adb, string propertyName, string errorWhat) + { + (bool success, string propertyValue) = await adb.GetPropertyValue (propertyName); + if (!success) { + log.LogWarning ($"Failed to get {errorWhat} from device"); + return default; + } + + return propertyValue; + } + + bool IsEmulator (string? model) + { + return false; + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/DotnetSymbolRunner.cs b/src/Xamarin.Android.Build.Tasks/Utilities/DotnetSymbolRunner.cs new file mode 100644 index 00000000000..9b8fed3d8aa --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/DotnetSymbolRunner.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +using Microsoft.Build.Utilities; + +namespace Xamarin.Android.Tasks +{ + class DotnetSymbolRunner : ToolRunner + { + public DotnetSymbolRunner (TaskLoggingHelper logger, string dotnetSymbolPath) + : base (logger, dotnetSymbolPath) + {} + + public async Task Fetch (string nativeLibraryPath, bool enableDiagnostics = false) + { + var runner = CreateProcessRunner (); + runner.AddArgument ("--symbols"); + runner.AddArgument ("--timeout").AddArgument ("1"); + runner.AddArgument ("--overwrite"); + + if (enableDiagnostics) { + runner.AddArgument ("--diagnostics"); + } + + runner.AddArgument (nativeLibraryPath); + + return await Run (runner); + } + + async Task Run (ProcessRunner runner) + { + return await RunTool ( + () => { + return runner.Run (); + } + ); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/ELFHelper.cs b/src/Xamarin.Android.Build.Tasks/Utilities/ELFHelper.cs index a7a00b4af60..56f2ea31ab7 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/ELFHelper.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/ELFHelper.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.IO; +using System.Text; using ELFSharp; using ELFSharp.ELF; @@ -15,6 +16,89 @@ namespace Xamarin.Android.Tasks { static class ELFHelper { + public static bool IsAOTLibrary (TaskLoggingHelper log, string path) + { + IELF? elf = ReadElfFile (log, path, "Unable to check if file is an AOT shared library."); + if (elf == null) { + return false; + } + + return IsAOTLibrary (elf); + } + + static bool IsAOTLibrary (IELF elf) + { + ISymbolTable? symtab = GetSymbolTable (elf, ".dynsym"); + if (symtab == null) { + // We can't be sure what the DSO is, play safe + return false; + } + + foreach (var entry in symtab.Entries) { + if (String.Compare ("mono_aot_file_info", entry.Name, StringComparison.Ordinal) == 0 && entry.Type == ELFSymbolType.Object) { + return true; + } + } + + return false; + } + + public static bool HasDebugSymbols (TaskLoggingHelper log, string path) + { + return HasDebugSymbols (log, path, out bool _); + } + + public static bool HasDebugSymbols (TaskLoggingHelper log, string path, out bool usesDebugLink) + { + usesDebugLink = false; + IELF? elf = ReadElfFile (log, path, "Skipping debug symbols presence check."); + if (elf == null) { + return false; + } + + if (HasDebugSymbols (elf)) { + return true; + } + + ISection? gnuDebugLink = GetSection (elf, ".gnu_debuglink"); + if (gnuDebugLink == null) { + return false; + } + usesDebugLink = true; + + byte[] contents = gnuDebugLink.GetContents (); + if (contents == null || contents.Length == 0) { + return false; + } + + // .gnu_debuglink section format: https://sourceware.org/gdb/current/onlinedocs/gdb/Separate-Debug-Files.html#index-_002egnu_005fdebuglink-sections + int nameEnd = -1; + for (int i = 0; i < contents.Length; i++) { + if (contents[i] == 0) { + nameEnd = i; + break; + } + } + + if (nameEnd < 2) { + // Name is terminated with a 0 byte, so we need at least 2 bytes + return false; + } + + string debugInfoFileName = Encoding.UTF8.GetString (contents, 0, nameEnd); + if (String.IsNullOrEmpty (debugInfoFileName)) { + return false; + } + + string debugFilePath = Path.Combine (Path.GetDirectoryName (path), debugInfoFileName); + return File.Exists (debugFilePath); + } + + static bool HasDebugSymbols (IELF elf) + { + return GetSymbolTable (elf, ".symtab") != null; + } + public static bool IsEmptyAOTLibrary (TaskLoggingHelper log, string path) { if (String.IsNullOrEmpty (path) || !File.Exists (path)) { @@ -73,26 +157,12 @@ static bool IsLibraryReference (IStringTable stringTable, IDynamicEntry dynEntry static bool IsEmptyAOTLibrary (TaskLoggingHelper log, string path, IELF elf) { - ISymbolTable? symtab = GetSymbolTable (elf, ".dynsym"); - if (symtab == null) { - // We can't be sure what the DSO is, play safe + if (!IsAOTLibrary (elf)) { + // Not a MonoVM AOT shared library return false; } - bool mono_aot_file_info_found = false; - foreach (var entry in symtab.Entries) { - if (String.Compare ("mono_aot_file_info", entry.Name, StringComparison.Ordinal) == 0 && entry.Type == ELFSymbolType.Object) { - mono_aot_file_info_found = true; - break; - } - } - - if (!mono_aot_file_info_found) { - // Not a MonoVM AOT assembly - return false; - } - - symtab = GetSymbolTable (elf, ".symtab"); + ISymbolTable? symtab = GetSymbolTable (elf, ".symtab"); if (symtab == null) { // The DSO is stripped, we can't tell if there are any functions defined (.text will be present anyway) // We perhaps **can** take a look at the .text section size, but it's not a solid check... @@ -161,5 +231,16 @@ bool IsNonEmptyCodeSymbol (SymbolEntry? symbolEntry) where T : struct return section; } + + static IELF? ReadElfFile (TaskLoggingHelper log, string path, string customErrorMessage) + { + try { + return ELFReader.Load (path); + } catch (Exception ex) { + log.LogWarning ($"{path} may not be a valid ELF binary. ${customErrorMessage}"); + log.LogWarningFromException (ex, showStackTrace: false); + return null; + } + } } } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/LldbRunner.cs b/src/Xamarin.Android.Build.Tasks/Utilities/LldbRunner.cs new file mode 100644 index 00000000000..285c1901092 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/LldbRunner.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +using Microsoft.Build.Utilities; +using Xamarin.Android.Tools; + +namespace Xamarin.Android.Tasks +{ + class LldbRunner : ToolRunner + { + bool needPythonEnvvars; + string scriptPath; + + public LldbRunner (TaskLoggingHelper logger, string lldbPath, string lldbScriptPath) + : base (logger, lldbPath) + { + // If we're invoking the executable directly, we need to set up Python environment variables or lldb won't run + string ext = Path.GetExtension (lldbPath); + if (String.Compare (".sh", ext, StringComparison.OrdinalIgnoreCase) == 0 || + String.Compare (".cmd", ext, StringComparison.OrdinalIgnoreCase) == 0 || + String.Compare (".bat", ext, StringComparison.OrdinalIgnoreCase) == 0) { + needPythonEnvvars = false; + } else { + needPythonEnvvars = true; + } + + scriptPath = lldbScriptPath; + + EchoStandardError = false; + EchoStandardOutput = false; + ProcessTimeout = TimeSpan.MaxValue; + } + + public bool Run () + { + var runner = CreateProcessRunner ("--source", scriptPath); + if (!OS.IsWindows) { + // lldb bundled with the NDK is unable to find it on its own. + // It searches for the database at "/buildbot/src/android/llvm-toolchain/out/lib/libncurses-linux-install/share/terminfo" + runner.Environment.Add ("TERMINFO", "/usr/share/terminfo"); + } + + if (needPythonEnvvars) { + // We assume our LLDB path is within the NDK root + string pythonDir = Path.GetFullPath (Path.Combine (Path.GetDirectoryName (ToolPath), "..", "python3")); + runner.Environment.Add ("PYTHONHOME", pythonDir); + + if (!OS.IsWindows) { + string envvarName = OS.IsMac ? "DYLD_LIBRARY_PATH" : "LD_LIBRARY_PATH"; + string oldLibraryPath = Environment.GetEnvironmentVariable (envvarName) ?? String.Empty; + string pythonLibDir = Path.Combine (pythonDir, "lib"); + runner.Environment.Add (envvarName, $"{pythonLibDir}:${oldLibraryPath}"); + } + } + + try { + return runner.Run (); + } catch (Exception ex) { + Logger.LogWarning ("LLDB failed with exception"); + Logger.LogWarningFromException (ex); + return false; + } + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/ManifestDocument.cs b/src/Xamarin.Android.Build.Tasks/Utilities/ManifestDocument.cs index 3f648c5cfcb..8d425a81ff2 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/ManifestDocument.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/ManifestDocument.cs @@ -95,6 +95,11 @@ internal class ManifestDocument public bool ForceDebuggable { get; set; } public string VersionName { get; set; } + /// + /// Available on API 29 or newer + /// + public bool Profileable { get; set; } + string versionCode; /// @@ -422,6 +427,12 @@ public IList Merge (TaskLoggingHelper log, TypeDefinitionCache cache, Li debuggable.Value = "true"; } + if (Profileable) { + var profileable = new XElement ("profileable"); + profileable.Add (new XAttribute (androidNs + "shell", "true")); + app.Add (profileable); + } + if (Debug || NeedsInternet) AddInternetPermissionForDebugger (); diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/MonoAndroidHelper.cs b/src/Xamarin.Android.Build.Tasks/Utilities/MonoAndroidHelper.cs index 179c78cb6b4..adab2d230cc 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/MonoAndroidHelper.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/MonoAndroidHelper.cs @@ -534,5 +534,19 @@ public static string GetRelativePathForAndroidAsset (string assetsDirectory, ITa path = head.Length == path.Length ? path : path.Substring ((head.Length == 0 ? 0 : head.Length + 1) + assetsDirectory.Length).TrimStart (DirectorySeparators); return path; } + + public static string? ReadManifestResource (TaskLoggingHelper log, string resourceName) + { + using (var from = System.Reflection.Assembly.GetExecutingAssembly ().GetManifestResourceStream (resourceName)) { + if (from == null) { + log.LogCodedError ("XA0116", Properties.Resources.XA0116, resourceName); + return null; + } + + using (var sr = new StreamReader (from)) { + return sr.ReadToEnd (); + } + } + } } } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/NativeDebugPrep.cs b/src/Xamarin.Android.Build.Tasks/Utilities/NativeDebugPrep.cs new file mode 100644 index 00000000000..76d94a1a3eb --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/NativeDebugPrep.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; + +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Utilities; +using Xamarin.Android.Tools; + +namespace Xamarin.Android.Tasks +{ + class NativeDebugPrep + { + const string ConfigScriptName = "debug-app-config"; + const string LldbScriptName = "lldb.x"; + + static HashSet xaLibraries = new HashSet (StringComparer.Ordinal) { + "libmonodroid.so", + "libxamarin-app.so", + "libxamarin-debug-app-helper.so", + }; + + TaskLoggingHelper log; + + public NativeDebugPrep (TaskLoggingHelper logger) + { + log = logger; + } + + [DllImport ("c", SetLastError=true, EntryPoint="chmod")] + private static extern int chmod (string path, uint mode); + + public void Prepare (string adbPath, string ndkRootPath, string activityName, + string outputDirRoot, string packageName, string[] supportedAbis, + Dictionary> nativeLibsPerAbi, string? targetDevice) + { + bool isWindows = OS.IsWindows; + string scriptResourceName = isWindows ? "debug-app.ps1" : "debug-app.sh"; + string? script = MonoAndroidHelper.ReadManifestResource (log, scriptResourceName); + + if (String.IsNullOrEmpty (script)) { + log.LogError ($"Failed to read script resource '{scriptResourceName}'"); + return; + } + + string? appLibrariesRoot = CopyLibraries (outputDirRoot, nativeLibsPerAbi); + string scriptConfigExt = isWindows ? ".ps1" : ".sh"; + string scriptOutput = Path.Combine (outputDirRoot, scriptResourceName); + + var sb = new StringBuilder (script); + + // TODO: perhaps use relative paths for APP_LIBS_DIR and OUTPUT_DIR? + sb.Replace ("@ACTIVITY_NAME@", activityName); + sb.Replace ("@ADB_PATH@", adbPath); + sb.Replace ("@APP_LIBS_DIR@", appLibrariesRoot ?? String.Empty); + sb.Replace ("@CONFIG_SCRIPT_NAME@", $"{ConfigScriptName}{scriptConfigExt}"); + sb.Replace ("@DEBUG_SESSION_PREP_PATH@", Path.Combine (Path.GetDirectoryName (typeof(NativeDebugPrep).Assembly.Location), "debug-session-prep.dll")); + sb.Replace ("@LLDB_SCRIPT_NAME@", LldbScriptName); + sb.Replace ("@NDK_DIR@", Path.GetFullPath (ndkRootPath)); + sb.Replace ("@OUTPUT_DIR@", Path.GetFullPath (outputDirRoot)); + sb.Replace ("@PACKAGE_NAME@", packageName); + + var abis = new StringBuilder (); + bool first = true; + foreach (string abi in supportedAbis) { + if (first) { + first = false; + } else { + abis.Append (isWindows ? ", " : " "); + } + abis.Append ($"\"{abi}\""); + } + sb.Replace ("@SUPPORTED_ABIS@", abis.ToString ()); + + Directory.CreateDirectory (Path.GetDirectoryName (scriptOutput)); + + using var fs = File.Open (scriptOutput, FileMode.Create, FileAccess.Write, FileShare.Read); + using var sw = new StreamWriter (fs, Files.UTF8withoutBOM); + + sw.Write (sb.ToString ()); + sw.Flush (); + sw.Close (); + fs.Close (); + + // 493 decimal is 0755 octal - makes the file rwx for the owner and rx for everybody else + if (!isWindows && chmod (scriptOutput, 493) != 0) { + log.LogWarning ($"Failed to make {scriptOutput} executable"); + } + + // TODO: color? + Console.WriteLine (); + Console.WriteLine ("You can start the debugging session by running the following command now:"); + Console.WriteLine ($" {scriptOutput}"); + Console.WriteLine (); + } + + string? CopyLibraries (string outputDirRoot, Dictionary> nativeLibsPerAbi) + { + if (nativeLibsPerAbi.Count == 0) { + return null; + } + + string appLibsRoot = Path.Combine (outputDirRoot, "lldb", "lib"); + log.LogDebugMessage ($"Copying application native libararies to {appLibsRoot}"); + + DotnetSymbolRunner? dotnetSymbol = GetDotnetSymbolRunner (); + bool haveLibsWithoutSymbols = false; + foreach (var kvp in nativeLibsPerAbi) { + string abi = kvp.Key; + List libs = kvp.Value; + + string abiDir = Path.Combine (appLibsRoot, abi); + foreach (string library in libs) { + log.LogDebugMessage ($" [{abi}] {library}"); + + string fileName = Path.GetFileName (library); + if (fileName.StartsWith ("libmono-android.")) { + fileName = "libmonodroid.so"; + } + + string destPath = Path.Combine (appLibsRoot, abi, fileName); + Directory.CreateDirectory (Path.GetDirectoryName (destPath)); + File.Copy (library, destPath, true); + + if (!EnsureSharedLibraryHasSymboles (destPath, dotnetSymbol)) { + haveLibsWithoutSymbols = true; + } + } + } + + if (haveLibsWithoutSymbols) { + log.LogWarning ($"One or more native libraries have no debug symbols."); + if (dotnetSymbol == null) { + log.LogWarning ($"The dotnet-symbol tool was not found. It can be installed using: dotnet tool install -g dotnet-symbol"); + } + } + + return Path.GetFullPath (appLibsRoot); + } + + bool EnsureSharedLibraryHasSymboles (string libraryPath, DotnetSymbolRunner? dotnetSymbol) + { + bool tryToFetchSymbols = false; + bool hasSymbols = ELFHelper.HasDebugSymbols (log, libraryPath, out bool usesDebugLink); + string libName = Path.GetFileName (libraryPath); + + if (!xaLibraries.Contains (libName)) { + if (ELFHelper.IsAOTLibrary (log, libraryPath)) { + return true; // We don't care about symbols, AOT libraries are only data + } + + // It might be a framework shared library, we'll try to fetch symbols if necessary and possible + tryToFetchSymbols = !hasSymbols && usesDebugLink; + } + + if (tryToFetchSymbols && dotnetSymbol != null) { + log.LogMessage ($"Attempting to download debug symbols from symbol server"); + if (!dotnetSymbol.Fetch (libraryPath).Result) { + log.LogWarning ($"Failed to download debug symbols for {libraryPath}"); + } + } + + hasSymbols = ELFHelper.HasDebugSymbols (log, libraryPath); + return hasSymbols; + } + + DotnetSymbolRunner? GetDotnetSymbolRunner () + { + string dotnetSymbolPath = Path.Combine (Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), ".dotnet", "tools", "dotnet-symbol"); + if (OS.IsWindows) { + dotnetSymbolPath = $"{dotnetSymbolPath}.exe"; + } + + if (!File.Exists (dotnetSymbolPath)) { + return null; + } + + return new DotnetSymbolRunner (log, dotnetSymbolPath); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/NativeDebugger.cs b/src/Xamarin.Android.Build.Tasks/Utilities/NativeDebugger.cs new file mode 100644 index 00000000000..8c63b12d763 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/NativeDebugger.cs @@ -0,0 +1,843 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Utilities; +using Xamarin.Android.Tools; + +using TPL = System.Threading.Tasks; + +namespace Xamarin.Android.Tasks +{ + // Starting LLDB server: /data/data/net.twistedcode.myapplication/lldb/bin/start_lldb_server.sh /data/data/net.twistedcode.myapplication/lldb unix-abstract /net.twistedcode.myapplication-0 platform-1668626161269.sock "lldb process:gdb-remote packets" + + /// + /// Interface to lldb, the NDK native code debugger. + /// + class NativeDebugger + { + const ConsoleColor ErrorColor = ConsoleColor.Red; + const ConsoleColor DebugColor = ConsoleColor.DarkGray; + const ConsoleColor InfoColor = ConsoleColor.Green; + const ConsoleColor MessageColor = ConsoleColor.Gray; + const ConsoleColor WarningColor = ConsoleColor.Yellow; + const ConsoleColor StatusLabel = ConsoleColor.Cyan; + const ConsoleColor StatusText = ConsoleColor.White; + + const string ServerLauncherScriptName = "xa_start_lldb_server.sh"; + + enum LogLevel + { + Error, + Warning, + Info, + Message, + Debug + } + + sealed class Context + { + public AdbRunner adb; + public int apiLevel; + public string abi; + public string arch; + public bool appIs64Bit; + public string appDataDir; + public string debugServerPath; + public string debugServerScriptPath; + public string debugSocketPath; + public string outputDir; + public string appLibrariesDir; + public string appLldbBaseDir; + public string appLldbBinDir; + public string appLldbLogDir; + public uint applicationPID; + public string lldbScriptPath; + public string zygotePath; + public int debugServerPort; + public string domainSocketDir; + public string platformSocketName; + public List? nativeLibraries; + public List deviceBinaryDirs; + } + + // We want the shell/batch scripts first, since they set up Python environment for the debugger + static readonly string[] lldbNames = { + "lldb.sh", + "lldb", + "lldb.cmd", + "lldb.exe", + }; + + static readonly string[] abiProperties = { + // new properties + "ro.product.cpu.abilist", + + // old properties + "ro.product.cpu.abi", + "ro.product.cpu.abi2", + }; + + static readonly string[] deviceLibraries = { + "libc.so", + "libm.so", + "libdl.so", + }; + + static HashSet xaLibraries = new HashSet (StringComparer.Ordinal) { + "libmonodroid.so", + "libxamarin-app.so", + "libxamarin-debug-app-helper.so", + }; + + static readonly object consoleLock = new object (); + static readonly UTF8Encoding UTF8NoBOM = new UTF8Encoding (false); + + TaskLoggingHelper log; + string packageName; + string lldbPath; + string adbPath; + string outputDir; + Dictionary hostLldbServerPaths; + string[] supportedAbis; + + public string? AdbDeviceTarget { get; set; } + public bool UseLldbGUI { get; set; } = true; + public string? CustomLldbCommandsFilePath { get; set; } + + public IDictionary>? NativeLibrariesPerABI { get; set; } + + public NativeDebugger (TaskLoggingHelper logger, string adbPath, string ndkRootPath, string outputDirRoot, string packageName, string[] supportedAbis) + { + this.log = logger; + this.packageName = packageName; + this.adbPath = adbPath; + this.supportedAbis = supportedAbis; + outputDir = Path.Combine (outputDirRoot, "native-debug"); + + if (!FindTools (ndkRootPath, supportedAbis)) { + throw new InvalidOperationException ("Failed to find all the required tools and utilities"); + } + } + + /// + /// Detect PID of the running application and attach the debugger to it + /// + public bool Attach () + { + Context? context = Init (); + + return context != null; + } + + /// + /// Launch the application under control of the debugger. + /// + public bool Launch (string activityName) + { + if (String.IsNullOrEmpty (activityName)) { + throw new ArgumentException ("must not be null or empty", nameof (activityName)); + } + + Context? context = Init (); + if (context == null) { + return false; + } + + // Start the app, tell it to wait for debugger to attach and to kill any running instance + // We tell `am` to wait ('-W') for the app to start, so that `pidof` then can find the process + string launchName = $"{packageName}/{activityName}"; + LogLine (); + LogStatusLine ("Launching activity", launchName); + LogLine ("Waiting for the activity to start..."); + (bool success, string output) = context.adb.Shell ("am", "start", "-S", "-W", launchName).Result; + if (!success) { + LogErrorLine ("Failed to launch the activity"); + return false; + } + + long appPID = GetDeviceProcessID (context, packageName); + if (appPID <= 0) { + LogErrorLine ("Failed to obtain PID of the running application"); + LogErrorLine (output); + return false; + } + context.applicationPID = (uint)appPID; + + LogStatusLine ("Application PID", $"{context.applicationPID}"); + + StartDebugServer (context); + + // GenerateLldbScript (context); + + // LogDebugLine ($"Starting LLDB: {lldbPath}"); + // var lldb = new LldbRunner (log, lldbPath, context.lldbScriptPath); + // if (lldb.Run ()) { + // LogWarning ("LLDB failed?"); + // } + + // KillDebugServer (context); + // LogDebugLine ("Waiting on the debug server process to quit"); + // (success, output) = debugServerTask.Result; + + return true; + } + + void GenerateLldbScript (Context context) + { + context.lldbScriptPath = Path.Combine (context.outputDir, $"{context.arch}-lldb-script.txt"); + + using (var f = File.OpenWrite (context.lldbScriptPath)) { + using (var sw = new StreamWriter (f, UTF8NoBOM)) { + string systemPaths = String.Join (" ", context.deviceBinaryDirs.Select (d => $"'{Path.GetFullPath(d)}'" )); + sw.WriteLine ($"settings append target.exec-search-paths '{Path.GetFullPath (context.appLibrariesDir)}' {systemPaths}"); + sw.WriteLine ($"target create '{Path.GetFullPath (context.zygotePath)}'"); + sw.WriteLine ($"target modules search-paths add / '{Path.GetFullPath (outputDir)}/'"); + sw.WriteLine ($"gdb-remote {context.debugServerPort}"); + + if (UseLldbGUI) { + sw.WriteLine ($"gui"); + } + + if (!String.IsNullOrEmpty (CustomLldbCommandsFilePath)) { + sw.WriteLine (); + sw.Write (File.ReadAllText (CustomLldbCommandsFilePath)); + sw.WriteLine (); + } + + sw.Flush (); + } + } + } + + bool KillDebugServer (Context context) + { + long serverPID = GetDeviceProcessID (context, context.debugServerPath, quiet: true); + if (serverPID <= 0) { + return true; + } + + LogDebugLine ("Killing previous instance of the debug server"); + (bool success, string _) = context.adb.RunAs (packageName, "kill", "-9", $"{serverPID}").Result; + return success; + } + + (AdbRunner? runner, TPL.Task<(bool success, string output)>? task) StartDebugServer (Context context) + { + LogDebugLine ($"Starting debug server on device: {context.debugServerScriptPath}"); + + if (!KillDebugServer (context)) { + LogWarningLine ("Failed to kill previous instance of the debug server"); + } + + context.domainSocketDir = $"/xa-{packageName}-0"; + + var rnd = new Random (); + context.platformSocketName = $"xa-platform-{rnd.Next ()}.sock"; + + var args = new List { + "shell", + "run-as", + packageName, + context.debugServerScriptPath, + context.appLldbBaseDir, // LLDB directory + "unix-abstract", // Listener socket scheme (unix-abstract: virtual, not on the filesystem) + context.domainSocketDir, // Directory where listener socket will be created + context.platformSocketName, // name of the socket to create + "'\"lldb process:gdb-remote packets\"'", // LLDB log channels + context.arch + }; + + string command = String.Join (" ", args); + LogDebugLine ($"Launch command: adb {command}"); + + var runner = CreateAdbRunner (); + runner.ProcessTimeout = TimeSpan.MaxValue; + + // TPL.Task<(bool success, string output)> task = runner.RunAs ( + // packageName, + // context.debugServerScriptPath, + // context.appLldbBaseDir, // LLDB directory + // "unix-abstract", // Listener socket scheme (unix-abstract: virtual, not on the filesystem) + // context.domainSocketDir, // Directory where listener socket will be created + // context.platformSocketName, // name of the socket to create + // "'\"lldb process:gdb-remote packets\"'", // LLDB log channels + // context.arch // LLDB architecture + // ); + + return (runner, null); + } + + long GetDeviceProcessID (Context context, string processName, bool quiet = false) + { + (bool success, string output) = context.adb.Shell ("pidof", processName).Result; + if (!success) { + if (!quiet) { + LogErrorLine ($"Failed to obtain PID of process '{processName}'"); + LogErrorLine (output); + } + return -1; + } + + output = output.Trim (); + if (!UInt32.TryParse (output, out uint pid)) { + if (!quiet) { + LogErrorLine ($"Unable to parse string '{output}' as the package's PID"); + } + return -1; + } + + return pid; + } + + AdbRunner CreateAdbRunner () => new AdbRunner (log, adbPath, AdbDeviceTarget); + + Context? Init () + { + LogLine (); + + var context = new Context { + adb = CreateAdbRunner () + }; + + (bool success, string output) = context.adb.GetPropertyValue ("ro.build.version.sdk").Result; + if (!success || String.IsNullOrEmpty (output) || !Int32.TryParse (output, out int apiLevel)) { + LogErrorLine ("Unable to determine connected device's API level"); + return null; + } + context.apiLevel = apiLevel; + + // Warn on old Pixel C firmware (b/29381985). Newer devices may have Yama + // enabled but still work with ndk-gdb (b/19277529). + (success, output) = context.adb.Shell ("cat", "/proc/sys/kernel/yama/ptrace_scope", "2>/dev/null").Result; + if (success && + YamaOK (output.Trim ()) && + PropertyIsEqualTo (context.adb.GetPropertyValue ("ro.build.product").Result, "dragon") && + PropertyIsEqualTo (context.adb.GetPropertyValue ("ro.product.name").Result, "ryu") + ) { + LogWarningLine ("WARNING: The device uses Yama ptrace_scope to restrict debugging. ndk-gdb will"); + LogWarningLine (" likely be unable to attach to a process. With root access, the restriction"); + LogWarningLine (" can be lifted by writing 0 to /proc/sys/kernel/yama/ptrace_scope. Consider"); + LogWarningLine (" upgrading your Pixel C to MXC89L or newer, where Yama is disabled."); + LogLine (); + } + + if (!DetermineArchitectureAndABI (context)) { + return null; + } + + if (!DetermineAppDataDirectory (context)) { + return null; + } + + if (!PushDebugServer (context)) { + return null; + } + + if (!CopyLibraries (context)) { + return null; + } + + context.debugSocketPath = $"{context.appDataDir}/debug_socket"; + + return context; + + bool YamaOK (string output) + { + return !String.IsNullOrEmpty (output) && String.Compare ("0", output, StringComparison.Ordinal) != 0; + } + + bool PropertyIsEqualTo ((bool haveProperty, string value) result, string expected) + { + return + result.haveProperty && + !String.IsNullOrEmpty (result.value) && + String.Compare (result.value, expected, StringComparison.Ordinal) == 0; + } + } + + bool EnsureSharedLibraryHasSymboles (string libraryPath, DotnetSymbolRunner? dotnetSymbol) + { + bool tryToFetchSymbols = false; + bool hasSymbols = ELFHelper.HasDebugSymbols (log, libraryPath, out bool usesDebugLink); + string libName = Path.GetFileName (libraryPath); + + if (!xaLibraries.Contains (libName)) { + if (ELFHelper.IsAOTLibrary (log, libraryPath)) { + return true; // We don't are about symbols, AOT libraries are only data + } + + // It might be a framework shared library, we'll try to fetch symbols if necessary and possible + tryToFetchSymbols = !hasSymbols && usesDebugLink; + } + + if (tryToFetchSymbols && dotnetSymbol != null) { + LogInfoLine ($" Attempting to download debug symbols from symbol server"); + if (!dotnetSymbol.Fetch (libraryPath).Result) { + LogWarningLine ($" Warning: failed to download debug symbols for {libraryPath}"); + } else { + LogLine (); + } + } + + hasSymbols = ELFHelper.HasDebugSymbols (log, libraryPath); + return hasSymbols; + } + + DotnetSymbolRunner? GetDotnetSymbolRunner () + { + string dotnetSymbolPath = Path.Combine (Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), ".dotnet", "tools", "dotnet-symbol"); + if (OS.IsWindows) { + dotnetSymbolPath = $"{dotnetSymbolPath}.exe"; + } + + if (!File.Exists (dotnetSymbolPath)) { + return null; + } + + return new DotnetSymbolRunner (log, dotnetSymbolPath); + } + + bool CopyLibraries (Context context) + { + LogInfoLine ("Populating local native library cache"); + context.appLibrariesDir = Path.Combine (context.outputDir, "app", "lib"); + if (!Directory.Exists (context.appLibrariesDir)) { + Directory.CreateDirectory (context.appLibrariesDir); + } + + if (context.nativeLibraries != null) { + LogInfoLine (" Copying application native libraries"); + bool haveLibsWithoutSymbols = false; + DotnetSymbolRunner? dotnetSymbol = GetDotnetSymbolRunner (); + + foreach (string library in context.nativeLibraries) { + LogLine ($" {library}"); + + string fileName = Path.GetFileName (library); + if (fileName.StartsWith ("libmono-android.")) { + fileName = "libmonodroid.so"; + } + string destPath = Path.Combine (context.appLibrariesDir, fileName); + File.Copy (library, destPath, true); + + if (!EnsureSharedLibraryHasSymboles (destPath, dotnetSymbol)) { + haveLibsWithoutSymbols = true; + } + } + + if (haveLibsWithoutSymbols) { + LogWarningLine ($"One or more native libraries have no debug symbols."); + if (dotnetSymbol == null) { + LogWarningLine ($"The dotnet-symbol tool was not found. It can be installed using: dotnet tool install -g dotnet-symbol"); + } + } + } + + var requiredFiles = new List (); + var libraries = new List (); + string libraryPath; + + if (context.appIs64Bit) { + libraryPath = "/system/lib64"; + + string zygotePath = "/system/bin/app_process64"; + requiredFiles.Add (zygotePath); + context.zygotePath = $"{context.outputDir}{ToLocalPathFormat (zygotePath)}"; + requiredFiles.Add ("/system/bin/linker64"); + } else { + libraryPath = "/system/lib"; + requiredFiles.Add ("/system/bin/linker"); + } + + foreach (string lib in deviceLibraries) { + requiredFiles.Add ($"{libraryPath}/{lib}"); + } + + LogLine (); + LogInfoLine (" Copying binaries from device"); + var dirs = new HashSet (StringComparer.Ordinal); + + foreach (string file in requiredFiles) { + string filePath = ToLocalPathFormat (file); + string localPath = $"{context.outputDir}{filePath}"; + string localDir = Path.GetDirectoryName (localPath); + + if (!Directory.Exists (localDir)) { + Directory.CreateDirectory (localDir); + if (!dirs.Contains (localDir)) { + dirs.Add (localDir); + } + } + + Log ($" From '{file}' to '{localPath}' "); + if (!context.adb.Pull (file, localPath).Result) { + LogLine ("[FAILED]"); + } else { + LogLine ("[SUCCESS]"); + } + } + + context.deviceBinaryDirs = new List (dirs); + if (context.appIs64Bit) { + return true; + } + + // /system/bin/app_process is 32-bit on 32-bit devices, but a symlink to + // # app_process64 on 64-bit. If we need the 32-bit version, try to pull + // # app_process32, and if that fails, pull app_process. + string destination = $"{context.outputDir}{ToLocalPathFormat ("/system/bin/app_process")}"; + string? source = "/system/bin/app_process32"; + + if (!context.adb.Pull (source, destination).Result) { + source = "/system/bin/app_process"; + if (!context.adb.Pull (source, destination).Result) { + source = null; + } + } + + if (String.IsNullOrEmpty (source)) { + LogErrorLine ("Failed to copy 32-bit app_process"); + return false; + } + LogLine ($" From '{source}' to '{destination}' "); + context.zygotePath = destination; + + return true; + + string ToLocalPathFormat (string path) => OS.IsWindows ? path.Replace ("/", "\\") : path; + } + + bool PushDebugServer (Context context) + { + if (!hostLldbServerPaths.TryGetValue (context.abi, out string debugServerPath)) { + LogErrorLine ($"Debug server for abi '{context.abi}' not found."); + return false; + } + + if (!context.adb.CreateDirectoryAs (packageName, context.appLldbBinDir).Result.success) { + LogErrorLine ($"Failed to create debug server destination directory on device, {context.appLldbBinDir}"); + return false; + } + + //string serverName = $"xa-{context.arch}-{Path.GetFileName (debugServerPath)}"; + string serverName = Path.GetFileName (debugServerPath); + context.debugServerPath = $"{context.appLldbBinDir}/{serverName}"; + + KillDebugServer (context); + + // Always push the server binary, as we don't know what version might already be there + if (!PushServerExecutable (context, debugServerPath, context.debugServerPath)) { + return false; + } + LogStatusLine ("Debug server path on device", context.debugServerPath); + + string? launcherScript = MonoAndroidHelper.ReadManifestResource (log, ServerLauncherScriptName); + if (String.IsNullOrEmpty (launcherScript)) { + return false; + } + + string launcherScriptPath = Path.Combine (context.outputDir, ServerLauncherScriptName); + Directory.CreateDirectory (Path.GetDirectoryName (launcherScriptPath)); + File.WriteAllText (launcherScriptPath, launcherScript, UTF8NoBOM); + + context.debugServerScriptPath = $"{context.appLldbBinDir}/{Path.GetFileName (launcherScriptPath)}"; + if (!PushServerExecutable (context, launcherScriptPath, context.debugServerScriptPath)) { + return false; + } + LogStatusLine ("Debug server launcher script path on device", context.debugServerScriptPath); + LogLine (); + + return true; + } + + bool PushServerExecutable (Context context, string hostSource, string deviceDestination) + { + string executableName = Path.GetFileName (deviceDestination); + + // Always push the executable, as we don't know what version might already be there + LogDebugLine ($"Uploading {hostSource} to device"); + + // First upload to temporary path, as it's writable for everyone + string remotePath = $"/data/local/tmp/{executableName}"; + if (!context.adb.Push (hostSource, remotePath).Result) { + LogErrorLine ($"Failed to upload debug server {hostSource} to device path {remotePath}"); + return false; + } + + // Next, copy it to the app dir, with run-as + (bool success, string output) = context.adb.Shell ( + "cat", remotePath, "|", + "run-as", packageName, + "sh", "-c", $"'cat > {deviceDestination}'" + ).Result; + + if (!success) { + LogErrorLine ($"Failed to copy debug executable to device, from {hostSource} to {deviceDestination}"); + return false; + } + + (success, output) = context.adb.RunAs (packageName, "chmod", "700", deviceDestination).Result; + if (!success) { + LogErrorLine ($"Failed to make debug server executable on device, at {deviceDestination}"); + return false; + } + + return true; + } + + bool DetermineAppDataDirectory (Context context) + { + (bool success, string output) = context.adb.GetAppDataDirectory (packageName).Result; + if (!success) { + LogErrorLine ($"Unable to determine data directory for package '{packageName}'"); + return false; + } + + context.appDataDir = output.Trim (); + LogStatusLine ($"Application data directory on device", context.appDataDir); + LogLine (); + + context.appLldbBaseDir = $"{context.appDataDir}/lldb"; + context.appLldbBinDir = $"{context.appLldbBaseDir}/bin"; + context.appLldbLogDir = $"{context.appLldbBaseDir}/log"; + + // Applications with minSdkVersion >= 24 will have their data directories + // created with rwx------ permissions, preventing adbd from forwarding to + // the gdbserver socket. To be safe, if we're on a device >= 24, always + // chmod the directory. + if (context.apiLevel >= 24) { + (success, output) = context.adb.RunAs (packageName, "/system/bin/chmod", "a+x", context.appDataDir).Result; + if (!success) { + LogErrorLine ("Failed to make application data directory world executable"); + return false; + } + } + + return true; + } + + bool DetermineArchitectureAndABI (Context context) + { + string[]? deviceABIs = null; + + foreach (string prop in abiProperties) { + (bool success, string value) = context.adb.GetPropertyValue (prop).Result; + if (!success) { + continue; + } + + deviceABIs = value.Split (','); + break; + } + + if (deviceABIs == null || deviceABIs.Length == 0) { + LogErrorLine ("Unable to determine device ABI"); + return false; + } + + LogABIs ("Application", supportedAbis); + LogABIs (" Device", deviceABIs); + + foreach (string deviceABI in deviceABIs) { + foreach (string appABI in supportedAbis) { + if (String.Compare (appABI, deviceABI, StringComparison.OrdinalIgnoreCase) == 0) { + context.abi = deviceABI; + context.arch = context.abi switch { + "armeabi" => "arm", + "armeabi-v7a" => "arm", + "arm64-v8a" => "arm64", + _ => context.abi, + }; + + LogStatusLine ($" Selected ABI", $"{context.abi} (architecture: {context.arch})"); + + context.appIs64Bit = context.abi.IndexOf ("64", StringComparison.Ordinal) >= 0; + context.outputDir = Path.Combine (outputDir, context.abi); + if (NativeLibrariesPerABI != null && NativeLibrariesPerABI.TryGetValue (context.abi, out List abiLibraries)) { + context.nativeLibraries = abiLibraries; + } + return true; + } + } + } + + LogErrorLine ($"Application cannot run on the selected device: no matching ABI found"); + return false; + + void LogABIs (string which, string[] abis) + { + LogStatusLine ($"{which} ABIs", String.Join (", ", abis)); + } + } + + string GetLlvmVersion (string toolchainDir) + { + string path = Path.Combine (toolchainDir, "AndroidVersion.txt"); + if (!File.Exists (path)) { + throw new InvalidOperationException ($"LLVM version file not found at '{path}'"); + } + + string[] lines = File.ReadAllLines (path); + string? line = lines.Length >= 1 ? lines[0].Trim () : null; + if (String.IsNullOrEmpty (line)) { + throw new InvalidOperationException ($"Unable to read LLVM version from '{path}'"); + } + + return line; + } + + bool FindTools (string ndkRootPath, string[] supportedAbis) + { + string toolchainDir = Path.Combine (ndkRootPath, NdkHelper.RelativeToolchainDir); + string toolchainBinDir = Path.Combine (toolchainDir, "bin"); + string? path = null; + + foreach (string lldb in lldbNames) { + path = Path.Combine (toolchainBinDir, lldb); + if (File.Exists (path)) { + break; + } + } + + if (String.IsNullOrEmpty (path)) { + LogErrorLine ($"Unable to locate lldb executable in '{toolchainBinDir}'"); + return false; + } + lldbPath = path; + + hostLldbServerPaths = new Dictionary (StringComparer.OrdinalIgnoreCase); + string llvmVersion = GetLlvmVersion (toolchainDir); + foreach (string abi in supportedAbis) { + string llvmAbi = NdkHelper.TranslateAbiToLLVM (abi); + path = Path.Combine (ndkRootPath, NdkHelper.RelativeToolchainDir, "lib64", "clang", llvmVersion, "lib", "linux", llvmAbi, "lldb-server"); + if (!File.Exists (path)) { + LogErrorLine ($"LLVM lldb server component for ABI '{abi}' not found at '{path}'"); + return false; + } + + hostLldbServerPaths.Add (abi, path); + } + + if (hostLldbServerPaths.Count == 0) { + LogErrorLine ("Unable to find any lldb-server executables, debugging not possible"); + return false; + } + + return true; + } + + void Log (string? message) + { + Log (LogLevel.Message, message); + } + + void LogLine (string? message = null) + { + Log ($"{message}{Environment.NewLine}"); + } + + void LogWarning (string? message) + { + Log (LogLevel.Warning, message); + } + + void LogWarningLine (string? message) + { + LogWarning ($"{message}{Environment.NewLine}"); + } + + void LogError (string? message) + { + Log (LogLevel.Error, message); + } + + void LogErrorLine (string? message) + { + LogError ($"{message}{Environment.NewLine}"); + } + + void LogInfo (string? message) + { + Log (LogLevel.Info, message); + } + + void LogInfoLine (string? message) + { + LogInfo ($"{message}{Environment.NewLine}"); + } + + void LogDebug (string? message) + { + Log (LogLevel.Debug, message); + } + + void LogDebugLine (string? message) + { + LogDebug ($"{message}{Environment.NewLine}"); + } + + void LogStatusLine (string label, string text) + { + Log (LogLevel.Info, $"{label}: ", StatusLabel); + Log (LogLevel.Info, $"{text}{Environment.NewLine}", StatusText); + } + + void Log (LogLevel level, string? message) + { + Log (level, message, ForegroundColor (level)); + } + + void Log (LogLevel level, string? message, ConsoleColor color) + { + TextWriter writer = level == LogLevel.Error ? Console.Error : Console.Out; + message = message ?? String.Empty; + + ConsoleColor fg = ConsoleColor.Gray; + try { + lock (consoleLock) { + fg = Console.ForegroundColor; + Console.ForegroundColor = color; + } + + writer.Write (message); + } finally { + Console.ForegroundColor = fg; + } + + if (!String.IsNullOrEmpty (message)) { + switch (level) { + case LogLevel.Error: + log.LogError (message); + break; + + case LogLevel.Warning: + log.LogWarning (message); + break; + + default: + case LogLevel.Message: + case LogLevel.Info: + log.LogMessage (message); + break; + + case LogLevel.Debug: + log.LogDebugMessage (message); + break; + } + } + } + + ConsoleColor ForegroundColor (LogLevel level) => level switch { + LogLevel.Error => ErrorColor, + LogLevel.Warning => WarningColor, + LogLevel.Info => InfoColor, + LogLevel.Debug => DebugColor, + LogLevel.Message => MessageColor, + _ => MessageColor, + }; + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/NdkHelper.cs b/src/Xamarin.Android.Build.Tasks/Utilities/NdkHelper.cs new file mode 100644 index 00000000000..f5062abeea5 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/NdkHelper.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; + +using System.Runtime.InteropServices; + +namespace Xamarin.Android.Tasks +{ + static class NdkHelper + { + static readonly string toolchainHostName; + static readonly string relativeToolchainDir; + + public static string ToolchainHostName => toolchainHostName; + public static string RelativeToolchainDir => relativeToolchainDir; + + static NdkHelper () + { + string os; + if (RuntimeInformation.IsOSPlatform (OSPlatform.Windows)) { + os = "windows"; + } else if (RuntimeInformation.IsOSPlatform (OSPlatform.OSX)) { + os = "darwin"; + } else if (RuntimeInformation.IsOSPlatform (OSPlatform.Linux)) { + os = "linux"; + } else { + throw new InvalidOperationException ($"Unsupported OS {RuntimeInformation.OSDescription}"); + } + + // We care only about the latest NDK versions, they have only x86_64 versions. We'll need to revisit the code once + // native macOS/arm64 toolchain is out. + toolchainHostName = $"{os}-x86_64"; + + relativeToolchainDir = Path.Combine ("toolchains", "llvm", "prebuilt", toolchainHostName); + } + + public static string TranslateAbiToLLVM (string xaAbi) + { + return xaAbi switch { + "armeabi-v7a" => "arm", + "arm64-v8a" => "aarch64", + "x86" => "i386", + "x86_64" => "x86_64", + _ => throw new InvalidOperationException ($"Unknown ABI '{xaAbi}'"), + }; + } + + public static string RIDToABI (string rid) + { + return rid switch { + "android-arm" => "armeabi-v7a", + "android-arm64" => "arm64-v8a", + "android-x86" => "x86", + "android-x64" => "x86_64", + _ => throw new InvalidOperationException ($"Unknown RID '{rid}'") + }; + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/ProcessRunner.cs b/src/Xamarin.Android.Build.Tasks/Utilities/ProcessRunner.cs new file mode 100644 index 00000000000..f7e45f8b822 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/ProcessRunner.cs @@ -0,0 +1,374 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; + +#if NO_MSBUILD +using LoggerType = Xamarin.Android.Utilities.XamarinLoggingHelper; +#else +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Utilities; + +using LoggerType = Microsoft.Build.Utilities.TaskLoggingHelper; +#endif + +namespace Xamarin.Android.Tasks +{ + class ProcessRunner + { + public const string StdoutSeverityName = "stdout | "; + public const string StderrSeverityName = "stderr | "; + + public enum ErrorReasonCode + { + NotExecutedYet, + NoError, + CommandNotFound, + ExecutionTimedOut, + ExitCodeNotZero, + }; + + static readonly TimeSpan DefaultProcessTimeout = TimeSpan.FromMinutes (5); + static readonly TimeSpan DefaultOutputTimeout = TimeSpan.FromSeconds (10); + + sealed class WriterGuard + { + public readonly object WriteLock = new object (); + public readonly TextWriter Writer; + + public WriterGuard (TextWriter writer) + { + Writer = writer; + } + } + + string command; + List? arguments; + List? stderrSinks; + List? stdoutSinks; + Dictionary? guardCache; + bool defaultStdoutEchoWrapperAdded; + ProcessStandardStreamWrapper? defaultStderrEchoWrapper; + LoggerType log; + + public string Command => command; + + public string Arguments { + get { + if (arguments == null) + return String.Empty; + return String.Join (" ", arguments); + } + } + + public string FullCommandLine { + get { + string args = Arguments; + if (String.IsNullOrEmpty (args)) + return command; + return $"{command} {args}"; + } + } + + public Dictionary Environment { get; } = new Dictionary (StringComparer.Ordinal); + public int ExitCode { get; private set; } = -1; + public ErrorReasonCode ErrorReason { get; private set; } = ErrorReasonCode.NotExecutedYet; + public bool EchoCmdAndArguments { get; set; } = true; + public bool EchoStandardOutput { get; set; } + public ProcessStandardStreamWrapper.LogLevel EchoStandardOutputLevel { get; set; } = ProcessStandardStreamWrapper.LogLevel.Message; + public bool EchoStandardError { get; set; } + public ProcessStandardStreamWrapper.LogLevel EchoStandardErrorLevel { get; set; } = ProcessStandardStreamWrapper.LogLevel.Error; + public ProcessStandardStreamWrapper? StandardOutputEchoWrapper { get; set; } + public ProcessStandardStreamWrapper? StandardErrorEchoWrapper { get; set; } + public Encoding StandardOutputEncoding { get; set; } = Encoding.Default; + public Encoding StandardErrorEncoding { get; set; } = Encoding.Default; + public TimeSpan StandardOutputTimeout { get; set; } = DefaultOutputTimeout; + public TimeSpan StandardErrorTimeout { get; set; } = DefaultOutputTimeout; + public TimeSpan ProcessTimeout { get; set; } = DefaultProcessTimeout; + public string? WorkingDirectory { get; set; } + public Action? StartInfoCallback { get; set; } + + public ProcessRunner (LoggerType logger, string command, params string?[] arguments) + : this (logger, command, false, arguments) + {} + + public ProcessRunner (LoggerType logger, string command, bool ignoreEmptyArguments, params string?[] arguments) + { + if (String.IsNullOrEmpty (command)) { + throw new ArgumentException ("must not be null or empty", nameof (command)); + } + + log = logger; + this.command = command; + AddArgumentsInternal (ignoreEmptyArguments, arguments); + } + + public ProcessRunner ClearArguments () + { + arguments?.Clear (); + return this; + } + + public ProcessRunner ClearOutputSinks () + { + stderrSinks?.Clear (); + stdoutSinks?.Clear (); + return this; + } + + public ProcessRunner AddArguments (params string?[]? arguments) + { + return AddArguments (true, arguments); + } + + public ProcessRunner AddArguments (bool ignoreEmptyArguments, params string?[]? arguments) + { + AddArgumentsInternal (ignoreEmptyArguments, arguments); + return this; + } + + void AddArgumentsInternal (bool ignoreEmptyArguments, params string?[]? arguments) + { + if (arguments == null || arguments.Length == 0) { + return; + } + + for (int i = 0; i < arguments.Length; i++) { + string? argument = arguments [i]?.Trim (); + if (String.IsNullOrEmpty (argument)) { + if (ignoreEmptyArguments) { + continue; + } + throw new InvalidOperationException ($"Argument {i} is null or empty"); + } + + AddQuotedArgument (argument!); + } + } + + public ProcessRunner AddArgument (string argument) + { + if (String.IsNullOrEmpty (argument)) { + throw new ArgumentException ("must not be null or empty", nameof (argument)); + } + + AddToList (argument, ref arguments); + return this; + } + + public ProcessRunner AddQuotedArgument (string argument) + { + if (String.IsNullOrEmpty (argument)) { + throw new ArgumentException ("must not be null or empty", nameof (argument)); + } + + return AddArgument (QuoteArgument (argument)); + } + + public static string QuoteArgument (string argument) + { + if (String.IsNullOrEmpty (argument)) { + return String.Empty; + } + + if (argument.IndexOf ('"') >= 0) { + argument = argument.Replace ("\"", "\\\""); + } + + return $"\"{argument}\""; + } + + public ProcessRunner AddStandardErrorSink (TextWriter writer) + { + if (writer == null) { + throw new ArgumentNullException (nameof (writer)); + } + + AddToList (GetGuard (writer), ref stderrSinks); + return this; + } + + public ProcessRunner AddStandardOutputSink (TextWriter writer) + { + if (writer == null) { + throw new ArgumentNullException (nameof (writer)); + } + + AddToList (GetGuard (writer), ref stdoutSinks); + return this; + } + + WriterGuard GetGuard (TextWriter writer) + { + if (guardCache == null) + guardCache = new Dictionary (); + + if (guardCache.TryGetValue (writer, out WriterGuard? ret) && ret != null) + return ret; + + ret = new WriterGuard (writer); + guardCache.Add (writer, ret); + return ret; + } + + public bool Run () + { + if (EchoStandardOutput) { + if (StandardOutputEchoWrapper != null) { + AddStandardOutputSink (StandardOutputEchoWrapper); + } else if (!defaultStdoutEchoWrapperAdded) { + AddStandardOutputSink (new ProcessStandardStreamWrapper (log) { LoggingLevel = EchoStandardOutputLevel, LogPrefix = StdoutSeverityName }); + defaultStdoutEchoWrapperAdded = true; + } + } + + if (EchoStandardError) { + if (StandardErrorEchoWrapper != null) { + AddStandardErrorSink (StandardErrorEchoWrapper); + } else if (defaultStderrEchoWrapper == null) { + defaultStderrEchoWrapper = new ProcessStandardStreamWrapper (log) { LoggingLevel = EchoStandardErrorLevel, LogPrefix = StderrSeverityName }; + AddStandardErrorSink (defaultStderrEchoWrapper); + } + } + + ManualResetEventSlim? stdout_done = null; + ManualResetEventSlim? stderr_done = null; + + if (stderrSinks != null && stderrSinks.Count > 0) { + stderr_done = new ManualResetEventSlim (false); + } + + if (stdoutSinks != null && stdoutSinks.Count > 0) { + stdout_done = new ManualResetEventSlim (false); + } + + var psi = new ProcessStartInfo (command) { + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + RedirectStandardError = stderr_done != null, + RedirectStandardOutput = stdout_done != null, + }; + + if (Environment.Count > 0) { + foreach (var kvp in Environment) { + psi.Environment.Add (kvp.Key, kvp.Value); + } + } + + if (arguments != null) { + psi.Arguments = String.Join (" ", arguments); + } + + if (!String.IsNullOrEmpty (WorkingDirectory)) { + psi.WorkingDirectory = WorkingDirectory; + } + + if (psi.RedirectStandardError) { + StandardErrorEncoding = StandardErrorEncoding; + } + + if (psi.RedirectStandardOutput) { + StandardOutputEncoding = StandardOutputEncoding; + } + + if (StartInfoCallback != null) { + StartInfoCallback (psi); + } + + var process = new Process { + StartInfo = psi + }; + + if (EchoCmdAndArguments) { + log.LogDebugMessage ($"Running: {FullCommandLine}"); + } + + try { + process.Start (); + } catch (System.ComponentModel.Win32Exception ex) { + log.LogError ($"Process failed to start: {ex.Message}"); + log.LogDebugMessage (ex.ToString ()); + + ErrorReason = ErrorReasonCode.CommandNotFound; + return false; + } + + if (psi.RedirectStandardError) { + process.ErrorDataReceived += (object sender, DataReceivedEventArgs e) => { + if (e.Data != null) { + WriteOutput (e.Data, stderrSinks!); + } else { + stderr_done!.Set (); + } + }; + process.BeginErrorReadLine (); + } + + if (psi.RedirectStandardOutput) { + process.OutputDataReceived += (object sender, DataReceivedEventArgs e) => { + if (e.Data != null) { + WriteOutput (e.Data, stdoutSinks!); + } else { + stdout_done!.Set (); + } + }; + process.BeginOutputReadLine (); + } + + int timeout = ProcessTimeout == TimeSpan.MaxValue ? -1 : (int)ProcessTimeout.TotalMilliseconds; + bool exited = process.WaitForExit (timeout); + if (!exited) { + log.LogError ($"Process '{FullCommandLine}' timed out after {ProcessTimeout}"); + ErrorReason = ErrorReasonCode.ExecutionTimedOut; + process.Kill (); + } + + // See: https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.process.waitforexit?view=netframework-4.7.2#System_Diagnostics_Process_WaitForExit) + if (psi.RedirectStandardError || psi.RedirectStandardOutput) { + process.WaitForExit (); + } + + if (stderr_done != null) { + stderr_done.Wait (StandardErrorTimeout); + } + + if (stdout_done != null) { + stdout_done.Wait (StandardOutputTimeout); + } + + ExitCode = process.ExitCode; + if (ExitCode != 0 && ErrorReason == ErrorReasonCode.NotExecutedYet) { + ErrorReason = ErrorReasonCode.ExitCodeNotZero; + return false; + } + + if (exited) { + ErrorReason = ErrorReasonCode.NoError; + } + + return exited; + } + + void WriteOutput (string data, List sinks) + { + foreach (WriterGuard wg in sinks) { + if (wg == null || wg.Writer == null) + continue; + + lock (wg.WriteLock) { + wg.Writer.WriteLine (data); + } + } + } + + void AddToList (T item, ref List? list) + { + if (list == null) + list = new List (); + list.Add (item); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/ProcessStandardStreamWrapper.cs b/src/Xamarin.Android.Build.Tasks/Utilities/ProcessStandardStreamWrapper.cs new file mode 100644 index 00000000000..907052dc473 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/ProcessStandardStreamWrapper.cs @@ -0,0 +1,88 @@ +using System; +using System.IO; +using System.Text; + +#if NO_MSBUILD +using LoggerType = Xamarin.Android.Utilities.XamarinLoggingHelper; +#else +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Utilities; + +using LoggerType = Microsoft.Build.Utilities.TaskLoggingHelper; +#endif + +namespace Xamarin.Android.Tasks +{ + class ProcessStandardStreamWrapper : TextWriter + { + public enum LogLevel + { + Error, + Warning, + Info, + Message, + Debug, + } + + LoggerType log; + + public LogLevel LoggingLevel { get; set; } = LogLevel.Debug; + public string? LogPrefix { get; set; } + + public override Encoding Encoding => Encoding.Default; + + public ProcessStandardStreamWrapper (LoggerType logger) + { + log = logger; + } + + public override void WriteLine (string? value) + { + DoWrite (value); + } + + protected virtual string? PreprocessMessage (string? message, out bool ignoreLine) + { + ignoreLine = false; + return message; + } + + void DoWrite (string? message) + { + bool ignoreLine; + + message = PreprocessMessage (message, out ignoreLine) ?? String.Empty; + if (ignoreLine) { + return; + } + + if (!String.IsNullOrEmpty (LogPrefix)) { + message = $"{LogPrefix}{message}"; + } + + switch (LoggingLevel) { + case LogLevel.Error: + log.LogError (message); + break; + + case LogLevel.Warning: + log.LogWarning (message); + break; + + case LogLevel.Info: + case LogLevel.Message: + log.LogMessage (message); + break; + + case LogLevel.Debug: + log.LogDebugMessage (message); + break; + + default: + log.LogWarning ($"Unsupported log level {LoggingLevel}"); + log.LogMessage (message); + break; + } + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/PythonRunner.cs b/src/Xamarin.Android.Build.Tasks/Utilities/PythonRunner.cs new file mode 100644 index 00000000000..dc60b76a5c5 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/PythonRunner.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; + +using Microsoft.Build.Utilities; + +namespace Xamarin.Android.Tasks +{ + class PythonRunner : ToolRunner + { + public PythonRunner (TaskLoggingHelper logger, string pythonPath) + : base (logger, pythonPath) + {} + + public async Task RunScript (string scriptPath, params string[] arguments) + { + ProcessRunner runner = CreateProcessRunner (); + + if (arguments != null && arguments.Length > 0) { + foreach (string arg in arguments) { + runner.AddArgument (arg); + } + } + + return await RunPython (runner); + } + + async Task RunPython (ProcessRunner runner) + { + return await RunTool ( + () => { + return runner.Run (); + } + ); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/ToolRunner.cs b/src/Xamarin.Android.Build.Tasks/Utilities/ToolRunner.cs new file mode 100644 index 00000000000..876a1fb33d2 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/ToolRunner.cs @@ -0,0 +1,92 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +#if NO_MSBUILD +using LoggerType = Xamarin.Android.Utilities.XamarinLoggingHelper; +#else // def NO_MSBUILD +using Microsoft.Android.Build.Tasks; + +using LoggerType = Microsoft.Build.Utilities.TaskLoggingHelper; +#endif // ndef NO_MSBUILD + +using TPLTask = System.Threading.Tasks.Task; + +namespace Xamarin.Android.Tasks +{ + abstract class ToolRunner + { + protected abstract class ToolOutputSink : TextWriter + { + protected string LogLinePrefix { get; set; } = String.Empty; + + LoggerType log; + + public override Encoding Encoding => Encoding.Default; + + protected ToolOutputSink (LoggerType logger) + { + log = logger; + } + + public override void WriteLine (string? value) + { + string message; + + if (!String.IsNullOrEmpty (LogLinePrefix)) { + message = $"{LogLinePrefix}> {value ?? String.Empty}"; + } else { + message = value ?? String.Empty; + } + + log.LogDebugMessage (message); + } + } + + static readonly TimeSpan DefaultProcessTimeout = TimeSpan.FromMinutes (15); + + protected LoggerType Logger { get; } + public string ToolPath { get; } + public bool EchoCmdAndArguments { get; set; } = true; + public bool EchoStandardError { get; set; } = true; + public bool EchoStandardOutput { get; set; } + public virtual TimeSpan ProcessTimeout { get; set; } = DefaultProcessTimeout; + + protected ToolRunner (LoggerType logger, string toolPath) + { + if (String.IsNullOrEmpty (toolPath)) { + throw new ArgumentException ("must not be null or empty", nameof (toolPath)); + } + + Logger = logger; + ToolPath = toolPath; + } + + protected virtual ProcessRunner CreateProcessRunner (params string?[]? initialParams) + { + var runner = new ProcessRunner (Logger, ToolPath) { + ProcessTimeout = ProcessTimeout, + EchoCmdAndArguments = EchoCmdAndArguments, + EchoStandardError = EchoStandardError, + EchoStandardOutput = EchoStandardOutput, + }; + + runner.AddArguments (initialParams); + return runner; + } + + protected virtual async Task RunTool (Func runner) + { + return await TPLTask.Run (runner); + } + + protected void SetupOutputSinks (ProcessRunner runner, TextWriter stdoutSink, TextWriter? stderrSink = null, bool ignoreStderr = false) + { + if (!ignoreStderr) { + runner.AddStandardErrorSink (stderrSink ?? stdoutSink); + } + runner.AddStandardOutputSink (stdoutSink); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj index b677c0ece0e..e07b13d6864 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj @@ -412,6 +412,12 @@ Resource.Designer.snk + + debug-app.sh + + + debug-app.ps1 + diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index c642f4507a7..77bb835fc92 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -333,6 +333,7 @@ Copyright (C) 2011-2012 Xamarin. All rights reserved. <_AndroidAotStripLibraries Condition=" '$(_AndroidAotStripLibraries)' == '' And '$(AndroidIncludeDebugSymbols)' != 'true' ">True + <_AndroidStripNativeLibraries Condition=" '$(_AndroidStripNativeLibraries)' == '' And '$(AndroidIncludeDebugSymbols)' != 'true' ">True false true True @@ -370,6 +371,7 @@ Copyright (C) 2011-2012 Xamarin. All rights reserved. Imports ******************************************* --> + @@ -1565,7 +1567,10 @@ because xbuild doesn't support framework reference assemblies. LinkingEnabled="$(_LinkingEnabled)" HaveMultipleRIDs="$(_HaveMultipleRIDs)" IntermediateOutputDirectory="$(IntermediateOutputPath)" - Environments="@(AndroidEnvironment);@(LibraryEnvironments)"> + Environments="@(AndroidEnvironment);@(LibraryEnvironments)" + NativeDebuggingEnabled="$(_AndroidEnableNativeDebugging)" + NativeCodeProfilingEnabled="$(_AndroidEnableNativeCodeProfiling)" + DeviceSdkVersion="$(_AndroidDeviceSdkVersion)"> @@ -2080,10 +2085,15 @@ because xbuild doesn't support framework reference assemblies. DependsOnTargets="_CompileNativeAssemblySources;_PrepareApplicationSharedLibraryItems" Inputs="@(_NativeAssemblyTarget)" Outputs="@(_ApplicationSharedLibrary)"> + + <_KeepDebugInfo Condition=" '$(_AndroidStripNativeLibraries)' != 'true' ">True + <_KeepDebugInfo Condition=" '$(_AndroidStripNativeLibraries)' == 'true' ">False + + @@ -2162,7 +2172,9 @@ because xbuild doesn't support framework reference assemblies. CheckedBuild="$(_AndroidCheckedBuild)" RuntimeConfigBinFilePath="$(_BinaryRuntimeConfigPath)" ExcludeFiles="@(AndroidPackagingOptionsExclude)" - UseAssemblyStore="$(AndroidUseAssemblyStore)"> + UseAssemblyStore="$(AndroidUseAssemblyStore)" + NativeCodeProfilingEnabled="$(_AndroidEnableNativeCodeProfiling)" + DeviceSdkVersion="$(_AndroidDeviceSdkVersion)"> + UseAssemblyStore="$(AndroidUseAssemblyStore)" + NativeCodeProfilingEnabled="$(_AndroidEnableNativeCodeProfiling)" + DeviceSdkVersion="$(_AndroidDeviceSdkVersion)"> +#include +#include +#include +#include +#include +#include + +#include "android-system.hh" +#include "globals.hh" +#include "logger.hh" +#include "simpleperf.hh" +#include "strings.hh" + +using namespace xamarin::android::internal; + +std::string +RecordOptions::get_default_output_filename () noexcept +{ + time_t t = time (nullptr); + + struct tm tm; + if (localtime_r (&t, &tm) != &tm) { + return "perf.data"; + } + + char* buf = nullptr; + + // TODO: don't use asprintf + asprintf (&buf, "perf-%02d-%02d-%02d-%02d-%02d.data", tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec); + + std::string result = buf; + free (buf); + + return result; +} + +std::vector +RecordOptions::to_record_args () noexcept +{ + std::vector args; + if (output_filename.empty ()) { + output_filename = get_default_output_filename (); + } + + args.insert (args.end (), {"-o", output_filename}); + args.insert (args.end (), {"-e", event}); + args.insert (args.end (), {"-f", std::to_string(freq)}); + + if (duration_in_seconds != 0.0) { + args.insert (args.end (), {"--duration", std::to_string (duration_in_seconds)}); + } + + if (threads.empty ()) { + args.insert (args.end (), {"-p", std::to_string (getpid ())}); + } else { + std::string threads_arg; + + for (auto const& thread_id : threads) { + if (!threads_arg.empty ()) { + threads_arg.append (","); + } + + threads_arg.append (std::to_string (thread_id)); + } + + args.insert(args.end (), {"-t", threads_arg}); + } + + if (dwarf_callgraph) { + args.push_back ("-g"); + } else if (fp_callgraph) { + args.insert (args.end (), {"--call-graph", "fp"}); + } + + if (trace_offcpu) { + args.push_back ("--trace-offcpu"); + } + + return args; +} + +ProfileSession::ProfileSession () noexcept +{ + std::string input_file {"/proc/self/cmdline"}; + FILE* fp = fopen (input_file.c_str (), "r"); + if (fp == nullptr) { + log_error (LOG_DEFAULT, "simpleperf: failed to open %s: %s", input_file.c_str (), strerror (errno)); + return; + } + + std::string s = read_file (fp, input_file); + for (size_t i = 0; i < s.size (); i++) { + if (s[i] == '\0') { + s = s.substr (0, i); + break; + } + } + + std::string app_data_dir = "/data/data/" + s; + uid_t uid = getuid (); + if (uid >= AID_USER_OFFSET) { + int user_id = uid / AID_USER_OFFSET; + app_data_dir = "/data/user/" + std::to_string (user_id) + "/" + s; + } + + session_valid = true; +} + +std::string +ProfileSession::read_file (FILE* fp, std::string const& path) noexcept +{ + std::string s; + if (fp == nullptr) { + return s; + } + + constexpr size_t BUF_SIZE = 200; + std::array buf; + + while (true) { + size_t n = fread (buf.data (), 1, buf.size (), fp); + if (n < buf.size ()) { + if (ferror (fp)) { + log_warn (LOG_DEFAULT, "simpleperf: an error occurred while reading input file %s: %s", path.c_str (), strerror (errno)); + } + + break; + } + + s.insert (s.end (), buf.data (), buf.data () + n); + } + + fclose (fp); + return s; +} + +bool +ProfileSession::session_is_valid () const noexcept +{ + if (session_valid) { + return true; + } + + log_warn (LOG_DEFAULT, "simpleperf: profiling session object hasn't been initialized properly, profiling will NOT produce any results"); + return false; +} + +std::string +ProfileSession::find_simpleperf_in_temp_dir () const noexcept +{ + const std::string path = "/data/local/tmp/simpleperf"; + if (!is_executable_file (path)) { + return ""; + } + // Copy it to app_dir to execute it. + const std::string to_path = app_data_dir_ + "/simpleperf"; + if (!run_cmd ({"/system/bin/cp", path.c_str(), to_path.c_str()}, nullptr)) { + return ""; + } + + // For apps with target sdk >= 29, executing app data file isn't allowed. + // For android R, app context isn't allowed to use perf_event_open. + // So test executing downloaded simpleperf. + std::string s; + if (!run_cmd ({to_path.c_str(), "list", "sw"}, &s)) { + return ""; + } + + if (s.find ("cpu-clock") == std::string::npos) { + return ""; + } + + return to_path; +} + +bool +ProfileSession::run_cmd (std::vector args, std::string* standard_output) noexcept +{ + std::array stdout_fd; + if (pipe (stdout_fd.data ()) != 0) { + return false; + } + + args.push_back (nullptr); + + // Fork handlers (like gsl_library_close) may hang in a multi-thread environment. + // So we use vfork instead of fork to avoid calling them. + int pid = vfork (); + if (pid == -1) { + log_warn (LOG_DEFAULT, "simpleperf: `vfork` failed: %s", strerror (errno)); + return false; + } + + if (pid == 0) { + // child process + close (stdout_fd[0]); + dup2 (stdout_fd[1], 1); + close (stdout_fd[1]); + + execvp (const_cast(args[0]), const_cast(args.data ())); + + log_error (LOG_DEFAULT, "simpleperf: failed to run %s: %s", args[0], strerror (errno)); + _exit (1); + } + + // parent process + close (stdout_fd[1]); + + int status; + pid_t result = TEMP_FAILURE_RETRY (waitpid (pid, &status, 0)); + if (result == -1) { + log_error (LOG_DEFAULT, "simpleperf: failed to call waitpid: %s", strerror (errno)); + } + + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + return false; + } + + if (standard_output == nullptr) { + close (stdout_fd[0]); + } else { + *standard_output = read_file (fdopen (stdout_fd[0], "r"), "pipe"); + } + + return true; +} + +bool +ProfileSession::is_executable_file (const std::string& path) noexcept +{ + struct stat st; + + if (stat (path.c_str (), &st) != 0) { + return false; + } + + return S_ISREG(st.st_mode) && ((st.st_mode & S_IXUSR) == S_IXUSR); +} + +std::string +ProfileSession::find_simpleperf () const noexcept +{ + // 1. Try /data/local/tmp/simpleperf first. Probably it's newer than /system/bin/simpleperf. + std::string simpleperf_path = find_simpleperf_in_temp_dir (); + if (!simpleperf_path.empty()) { + return simpleperf_path; + } + + // 2. Try /system/bin/simpleperf, which is available on Android >= Q. + simpleperf_path = "/system/bin/simpleperf"; + if (is_executable_file (simpleperf_path)) { + return simpleperf_path; + } + + log_error (LOG_DEFAULT, "simpleperf: can't find simpleperf on device. Please run api_profiler.py."); + return ""; +} + +bool +ProfileSession::check_if_perf_enabled () noexcept +{ + dynamic_local_string prop; + + if (androidSystem.monodroid_get_system_property ("persist.simpleperf.profile_app_uid", prop) <= 0) { + return false; + } + + if (prop.get () == std::to_string (getuid ())) { + prop.clear (); + + androidSystem.monodroid_get_system_property ("persist.simpleperf.profile_app_expiration_time", prop); + if (!prop.empty ()) { + errno = 0; + long expiration_time = strtol (prop.get (), nullptr, 10); + if (errno == 0 && expiration_time > time (nullptr)) { + return true; + } + } + } + + if (androidSystem.monodroid_get_system_property ("security.perf_harden", prop) <= 0 || prop.empty ()) { + return true; + } + + if (prop.get ()[0] == '1') { + log_error (LOG_DEFAULT, "simpleperf: recording app isn't enabled on the device. Please run api_profiler.py."); + return false; + } + + return true; +} + +bool +ProfileSession::create_simpleperf_data_dir () const noexcept +{ + struct stat st; + if (stat (simpleperf_data_dir_.c_str (), &st) == 0 && S_ISDIR (st.st_mode)) { + return true; + } + + if (mkdir (simpleperf_data_dir_.c_str (), 0700) == -1) { + log_error (LOG_DEFAULT, "simpleperf: failed to create simpleperf data dir %s: %s", simpleperf_data_dir_.c_str(), strerror (errno)); + return false; + } + + return true; +} + +bool +ProfileSession::create_simpleperf_process (std::string const& simpleperf_path, std::vector const& record_args) noexcept +{ + // 1. Create control/reply pips. + std::array control_fd { -1, -1 }; + std::array reply_fd { -1, -1 }; + + if (pipe (control_fd.data ()) != 0 || pipe (reply_fd.data ()) != 0) { + log_error (LOG_DEFAULT, "simpleperf: failed to call pipe: %s", strerror (errno)); + return false; + } + + // 2. Prepare simpleperf arguments. + std::vector args; + args.emplace_back (simpleperf_path); + args.emplace_back ("record"); + args.emplace_back ("--log-to-android-buffer"); + args.insert (args.end (), {"--log", "debug"}); + args.emplace_back ("--stdio-controls-profiling"); + args.emplace_back ("--in-app"); + args.insert (args.end (), {"--tracepoint-events", "/data/local/tmp/tracepoint_events"}); + args.insert (args.end (), record_args.begin (), record_args.end ()); + + char* argv[args.size () + 1]; + for (size_t i = 0; i < args.size (); ++i) { + argv[i] = &args[i][0]; + } + argv[args.size ()] = nullptr; + + // 3. Start simpleperf process. + // Fork handlers (like gsl_library_close) may hang in a multi-thread environment. + // So we use vfork instead of fork to avoid calling them. + int pid = vfork (); + if (pid == -1) { + auto close_fds = [](std::array const& fds) { + for (auto fd : fds) { + close (fd); + } + }; + + log_error (LOG_DEFAULT, "simpleperf: failed to fork: %s", strerror (errno)); + close_fds (control_fd); + close_fds (reply_fd); + + return false; + } + + if (pid == 0) { + // child process + close (control_fd[1]); + dup2 (control_fd[0], 0); // simpleperf read control cmd from fd 0. + close (control_fd[0]); + close (reply_fd[0]); + dup2 (reply_fd[1], 1); // simpleperf writes reply to fd 1. + close (reply_fd[0]); + chdir (simpleperf_data_dir_.c_str()); + execvp (argv[0], argv); + + log_fatal (LOG_DEFAULT, "simpleperf: failed to call exec: %s", strerror (errno)); + } + + // parent process + close (control_fd[0]); + control_fd_ = control_fd[1]; + close (reply_fd[1]); + reply_fd_ = reply_fd[0]; + simpleperf_pid_ = pid; + + // 4. Wait until simpleperf starts recording. + std::string start_flag = read_reply (); + if (start_flag != "started") { + log_error (LOG_DEFAULT, "simpleperf: failed to receive simpleperf start flag"); + return false; + } + + return true; +} + +void +ProfileSession::start_recording (std::vector const& record_args) noexcept +{ + if (!session_is_valid () || !check_if_perf_enabled ()) { + return; + } + + std::lock_guard guard {lock_}; + if (state_ != State::NOT_YET_STARTED) { + log_error (LOG_DEFAULT, "simpleperf: start_recording: session in wrong state %d", state_); + } + + for (auto const& arg : record_args) { + if (arg == "--trace-offcpu") { + trace_offcpu_ = true; + } + } + + std::string simpleperf_path = find_simpleperf (); + + if (!create_simpleperf_data_dir ()) { + return; + } + + if (!create_simpleperf_process (simpleperf_path, record_args)) { + return; + } + + state_ = State::STARTED; +} + +void +ProfileSession::pause_recording () noexcept +{ + if (!session_is_valid ()) { + return; + } + + std::lock_guard guard(lock_); + if (state_ != State::STARTED) { + log_error (LOG_DEFAULT, "simpleperf: pause_recording: session in wrong state %d", state_); + return; + } + + if (trace_offcpu_) { + log_warn (LOG_DEFAULT, "simpleperf: --trace-offcpu doesn't work well with pause/resume recording"); + } + + if (!send_cmd ("pause")) { + return; + } + + state_ = State::PAUSED; +} + +void +ProfileSession::resume_recording () noexcept +{ + if (!session_is_valid ()) { + return; + } + + std::lock_guard guard {lock_}; + + if (state_ != State::PAUSED) { + log_error (LOG_DEFAULT, "simpleperf: resume_recording: session in wrong state %d", state_); + } + + if (!send_cmd ("resume")) { + return; + } + + state_ = State::STARTED; +} + +void +ProfileSession::stop_recording () noexcept +{ + if (!session_is_valid ()) { + return; + } + + std::lock_guard guard {lock_}; + + if (state_ != State::STARTED && state_ != State::PAUSED) { + log_error (LOG_DEFAULT, "simpleperf: stop_recording: session in wrong state %d", state_); + return; + } + + // Send SIGINT to simpleperf to stop recording. + if (kill (simpleperf_pid_, SIGINT) == -1) { + log_error (LOG_DEFAULT, "simpleperf: failed to stop simpleperf: %s", strerror (errno)); + return; + } + + int status; + pid_t result = TEMP_FAILURE_RETRY(waitpid(simpleperf_pid_, &status, 0)); + if (result == -1) { + log_error (LOG_DEFAULT, "simpleperf: failed to call waitpid: %s", strerror (errno)); + return; + } + + if (!WIFEXITED (status) || WEXITSTATUS (status) != 0) { + log_error (LOG_DEFAULT, "simpleperf: simpleperf exited with error, status = 0x%x", status); + return; + } + + state_ = State::STOPPED; +} + +std::string +ProfileSession::read_reply () noexcept +{ + std::string s; + while (true) { + char c; + ssize_t result = TEMP_FAILURE_RETRY (read (reply_fd_, &c, 1)); + if (result <= 0 || c == '\n') { + break; + } + s.push_back(c); + } + + return s; +} + +bool +ProfileSession::send_cmd (std::string const& cmd) noexcept +{ + std::string data = cmd + "\n"; + + if (TEMP_FAILURE_RETRY (write (control_fd_, &data[0], data.size())) != static_cast(data.size ())) { + log_error (LOG_DEFAULT, "simpleperf: failed to send cmd to simpleperf: %s", strerror (errno)); + return false; + } + + if (read_reply () != "ok") { + log_error (LOG_DEFAULT, "simpleperf: failed to run cmd in simpleperf: %s", cmd.c_str ()); + return false; + } + + return true; +} diff --git a/src/monodroid/jni/simpleperf.hh b/src/monodroid/jni/simpleperf.hh new file mode 100644 index 00000000000..2f737d73b4c --- /dev/null +++ b/src/monodroid/jni/simpleperf.hh @@ -0,0 +1,246 @@ +// +// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: Apache-2.0 +// +// Support for managing simpleperf session from within the runtime +// +// Heavily based on https://android.googlesource.com/platform/system/extras/+/refs/tags/android-13.0.0_r11/simpleperf/app_api/cpp/simpleperf.cpp +// +// Because the original code is licensed under the `Apache-2.0` license, this file is dual-licensed under the `MIT` and +// `Apache-2.0` licenses +// +// We can't use the original source because of the C++ stdlib features it uses (I/O streams which we can't use because +// we don't reference libc++) +// +// The API is very similar to the original, with occasional stylistic changes and some behavioral changes (for instance, +// we do not abort the process if tracing fails - instead we log errors and continue running). Portions are +// reimplemented in a way that better fits Xamarin.Android's purposes. We also don't split the classes into interface +// and implementation bits - there's no need since we use this API entirely internally. +// +// Stylistic changes include indentation and renaming of all methods to use lower-case words separated by underscores instead of +// camel case (because that's the style Xamarin.Android sources use). Names are otherwise the same as in the original +// code, for easier porting of potential changes. +// +#if !defined (__SIMPLEPERF_HH) +#define __SIMPLEPERF_HH + +#include +#include + +#include "cppcompat.hh" + +namespace xamarin::android::internal +{ + enum class RecordCmd + { + CMD_PAUSE_RECORDING = 1, + CMD_RESUME_RECORDING, + }; + + /** + * RecordOptions sets record options used by ProfileSession. The options are + * converted to a string list in toRecordArgs(), which is then passed to + * `simpleperf record` cmd. Run `simpleperf record -h` or + * `run_simpleperf_on_device.py record -h` for help messages. + * + * Example: + * RecordOptions options; + * options.set_duration (3).record_dwarf_call_graph ().set_output_filename ("perf.data"); + * ProfileSession session; + * session.start_recording (options); + */ + class RecordOptions final + { + public: + /** + * Set output filename. Default is perf-----.data. + * The file will be generated under simpleperf_data/. + */ + RecordOptions& set_output_filename (std::string const& filename) noexcept + { + output_filename = filename; + return *this; + } + + /** + * Set event to record. Default is cpu-cycles. See `simpleperf list` for all available events. + */ + RecordOptions& set_event (std::string const& wanted_event) noexcept + { + event = wanted_event; + return *this; + } + + /** + * Set how many samples to generate each second running. Default is 4000. + */ + RecordOptions& set_sample_frequency (size_t wanted_freq) noexcept + { + freq = wanted_freq; + return *this; + } + + /** + * Set record duration. The record stops after `durationInSecond` seconds. By default, + * record stops only when stopRecording() is called. + */ + RecordOptions& set_duration (double wanted_duration_in_seconds) noexcept + { + duration_in_seconds = wanted_duration_in_seconds; + return *this; + } + + /** + * Record some threads in the app process. By default, record all threads in the process. + */ + RecordOptions& set_sample_threads (std::vector const& wanted_threads) noexcept + { + threads = wanted_threads; + return *this; + } + + /** + * Record dwarf based call graph. It is needed to get Java callstacks. + */ + RecordOptions& record_dwarf_call_graph () noexcept + { + dwarf_callgraph = true; + fp_callgraph = false; + return *this; + } + + /** + * Record frame pointer based call graph. It is suitable to get C++ callstacks on 64bit devices. + */ + RecordOptions& record_frame_pointer_call_graph () noexcept + { + fp_callgraph = true; + dwarf_callgraph = false; + return *this; + } + + /** + * Trace context switch info to show where threads spend time off cpu. + */ + RecordOptions& trace_off_cpu () noexcept + { + trace_offcpu = true; + return *this; + } + + /** + * Translate record options into arguments for `simpleperf record` cmd. + */ + std::vector to_record_args () noexcept; + + private: + static std::string get_default_output_filename () noexcept; + + private: + std::string output_filename; + std::string event = "cpu-cycles"; + size_t freq = 4000; + double duration_in_seconds = 0.0; + std::vector threads; + bool dwarf_callgraph = false; + bool fp_callgraph = false; + bool trace_offcpu = false; + }; + + + enum class State + { + NOT_YET_STARTED, + STARTED, + PAUSED, + STOPPED, + }; + + /** + * ProfileSession uses `simpleperf record` cmd to generate a recording file. + * It allows users to start recording with some options, pause/resume recording + * to only profile interested code, and stop recording. + * + * Example: + * RecordOptions options; + * options.set_dwarf_call_graph (); + * ProfileSession session; + * session.start_recording (options); + * sleep(1); + * session.pause_recording (); + * sleep(1); + * session.resume_recording (); + * sleep(1); + * session.stop_recording (); + * + * It logs when error happens, does not abort the process. To read error messages of simpleperf record + * process, filter logcat with `simpleperf`. + */ + class ProfileSession final + { + private: + static constexpr uid_t AID_USER_OFFSET = 100000; + + public: + ProfileSession () noexcept; + + /** + * Start recording. + * @param options RecordOptions + */ + void start_recording (RecordOptions& options) noexcept + { + start_recording (options.to_record_args ()); + } + + /** + * Start recording. + * @param args arguments for `simpleperf record` cmd. + */ + void start_recording (std::vector const& record_args) noexcept; + + /** + * Pause recording. No samples are generated in paused state. + */ + void pause_recording () noexcept; + + /** + * Resume a paused session. + */ + void resume_recording () noexcept; + + /** + * Stop recording and generate a recording file under appDataDir/simpleperf_data/. + */ + void stop_recording () noexcept; + + private: + bool session_is_valid () const noexcept; + + std::string find_simpleperf_in_temp_dir () const noexcept; + std::string find_simpleperf () const noexcept; + bool create_simpleperf_data_dir () const noexcept; + bool create_simpleperf_process (std::string const& simpleperf_path, std::vector const& record_args) noexcept; + std::string read_reply () noexcept; + bool send_cmd (std::string const& cmd) noexcept; + + static std::string read_file (FILE* fp, std::string const& path) noexcept; + static bool is_executable_file (const std::string& path) noexcept; + static bool run_cmd (std::vector args, std::string* standard_output) noexcept; + static bool check_if_perf_enabled () noexcept; + + private: + // Clunky, but we want error in initialization to be non-fatal to the app + bool session_valid = false; + + const std::string app_data_dir_; + const std::string simpleperf_data_dir_; + std::mutex lock_; // Protect all members below. + State state_ = State::NOT_YET_STARTED; + pid_t simpleperf_pid_ = -1; + int control_fd_ = -1; + int reply_fd_ = -1; + bool trace_offcpu_ = false; + }; +} +#endif // ndef __SIMPLEPERF_HH diff --git a/tools/debug-session-prep/Debug.Session.Prep/AndroidDevice.cs b/tools/debug-session-prep/Debug.Session.Prep/AndroidDevice.cs new file mode 100644 index 00000000000..c91199f532a --- /dev/null +++ b/tools/debug-session-prep/Debug.Session.Prep/AndroidDevice.cs @@ -0,0 +1,435 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Xamarin.Android.Tasks; +using Xamarin.Android.Utilities; + +namespace Xamarin.Debug.Session.Prep; + +class AndroidDevice +{ + const string ServerLauncherScriptName = "xa_start_lldb_server.sh"; + + static readonly string[] abiProperties = { + // new properties + "ro.product.cpu.abilist", + + // old properties + "ro.product.cpu.abi", + "ro.product.cpu.abi2", + }; + + static readonly string[] serialNumberProperties = { + "ro.serialno", + "ro.boot.serialno", + }; + + string packageName; + string adbPath; + string[] supportedAbis; + int apiLevel = -1; + string? appDataDir; + string? appLldbBaseDir; + string? appLldbBinDir; + string? appLldbLogDir; + string? appLldbTmpDir; + string? mainAbi; + string? mainArch; + string[]? availableAbis; + string[]? availableArches; + string? serialNumber; + bool appIs64Bit; + string? deviceLdd; + string? deviceDebugServerPath; + string? deviceDebugServerScriptPath; + string outputDir; + + XamarinLoggingHelper log; + AdbRunner adb; + AndroidNdk ndk; + + public int ApiLevel => apiLevel; + public string[] AvailableAbis => availableAbis ?? new string[] {}; + public string[] AvailableArches => availableArches ?? new string[] {}; + public string MainArch => mainArch ?? String.Empty; + public string MainAbi => mainAbi ?? String.Empty; + public string SerialNumber => serialNumber ?? String.Empty; + public string DebugServerLauncherScriptPath => deviceDebugServerScriptPath; + public string LldbBaseDir => appLldbBaseDir; + public AdbRunner AdbRunner => adb; + + public AndroidDevice (XamarinLoggingHelper log, AndroidNdk ndk, string outputDir, string adbPath, string packageName, string[] supportedAbis, string? adbTargetDevice = null) + { + this.adbPath = adbPath; + this.log = log; + this.packageName = packageName; + this.supportedAbis = supportedAbis; + this.ndk = ndk; + this.outputDir = outputDir; + + adb = new AdbRunner (log, adbPath, adbTargetDevice); + } + + // TODO: implement manual error checking on API 21, since `adb` won't ever return any error code other than 0 - we need to look at the output of any command to determine + // whether or not it was successful. Ugh. + public bool GatherInfo () + { + (bool success, string output) = adb.GetPropertyValue ("ro.build.version.sdk").Result; + if (!success || String.IsNullOrEmpty (output) || !Int32.TryParse (output, out apiLevel)) { + log.ErrorLine ("Unable to determine connected device's API level"); + return false; + } + + // Warn on old Pixel C firmware (b/29381985). Newer devices may have Yama + // enabled but still work with ndk-gdb (b/19277529). + (success, output) = adb.Shell ("cat", "/proc/sys/kernel/yama/ptrace_scope", "2>/dev/null").Result; + if (success && + YamaOK (output.Trim ()) && + PropertyIsEqualTo (adb.GetPropertyValue ("ro.build.product").Result, "dragon") && + PropertyIsEqualTo (adb.GetPropertyValue ("ro.product.name").Result, "ryu") + ) { + log.WarningLine ("WARNING: The device uses Yama ptrace_scope to restrict debugging. ndk-gdb will"); + log.WarningLine (" likely be unable to attach to a process. With root access, the restriction"); + log.WarningLine (" can be lifted by writing 0 to /proc/sys/kernel/yama/ptrace_scope. Consider"); + log.WarningLine (" upgrading your Pixel C to MXC89L or newer, where Yama is disabled."); + log.WarningLine (); + } + + if (!DetermineArchitectureAndABI ()) { + return false; + } + + if (!DetermineAppDataDirectory ()) { + return false; + } + + serialNumber = GetFirstFoundPropertyValue (serialNumberProperties); + if (String.IsNullOrEmpty (serialNumber)) { + log.WarningLine ("Unable to determine device serial number"); + } else { + log.StatusLine ($"Device serial number", serialNumber); + } + + return true; + + bool YamaOK (string output) + { + return !String.IsNullOrEmpty (output) && String.Compare ("0", output, StringComparison.Ordinal) != 0; + } + + bool PropertyIsEqualTo ((bool haveProperty, string value) result, string expected) + { + return + result.haveProperty && + !String.IsNullOrEmpty (result.value) && + String.Compare (result.value, expected, StringComparison.Ordinal) == 0; + } + } + + public bool Prepare (out string? mainProcessPath) + { + mainProcessPath = null; + if (!DetectTools ()) { + return false; + } + + if (!PushDebugServer ()) { + return false; + } + + if (!PullLibraries (out mainProcessPath)) { + return false; + } + + return true; + } + + bool PullLibraries (out string? mainProcessPath) + { + mainProcessPath = null; + DeviceLibraryCopier copier; + + if (String.IsNullOrEmpty (deviceLdd)) { + copier = new NoLddDeviceLibraryCopier ( + log, + adb, + appIs64Bit, + outputDir, + this + ); + } else { + copier = new LddDeviceLibraryCopier ( + log, + adb, + appIs64Bit, + outputDir, + this + ); + } + + return copier.Copy (out mainProcessPath); + } + + bool PushDebugServer () + { + string? debugServerPath = ndk.GetDebugServerPath (mainAbi!); + if (String.IsNullOrEmpty (debugServerPath)) { + return false; + } + + if (!CreateLldbDir (appLldbBinDir!) || !CreateLldbDir (appLldbLogDir) || !CreateLldbDir (appLldbTmpDir)) { + return false; + } + + //string serverName = $"xa-{context.arch}-{Path.GetFileName (debugServerPath)}"; + string serverName = Path.GetFileName (debugServerPath); + deviceDebugServerPath = $"{appLldbBinDir}/{serverName}"; + + KillDebugServer (deviceDebugServerPath); + + // Always push the server binary, as we don't know what version might already be there + if (!PushServerExecutable (debugServerPath, deviceDebugServerPath)) { + return false; + } + log.StatusLine ("Debug server path on device", deviceDebugServerPath); + + string? launcherScript = Utilities.ReadManifestResource (log, ServerLauncherScriptName); + if (String.IsNullOrEmpty (launcherScript)) { + return false; + } + + string launcherScriptPath = Path.Combine (outputDir, ServerLauncherScriptName); + Directory.CreateDirectory (Path.GetDirectoryName (launcherScriptPath)!); + File.WriteAllText (launcherScriptPath, launcherScript, Utilities.UTF8NoBOM); + + deviceDebugServerScriptPath = $"{appLldbBinDir}/{Path.GetFileName (launcherScriptPath)}"; + if (!PushServerExecutable (launcherScriptPath, deviceDebugServerScriptPath)) { + return false; + } + log.StatusLine ("Debug server launcher script path on device", deviceDebugServerScriptPath); + log.MessageLine (); + + return true; + + bool CreateLldbDir (string dir) + { + if (!adb.CreateDirectoryAs (packageName, dir).Result.success) { + log.ErrorLine ($"Failed to create debug server destination directory on device, {dir}"); + return false; + } + + return true; + } + } + + bool PushServerExecutable (string hostSource, string deviceDestination) + { + string executableName = Path.GetFileName (deviceDestination); + + // Always push the executable, as we don't know what version might already be there + log.DebugLine ($"Uploading {hostSource} to device"); + + // First upload to temporary path, as it's writable for everyone + string remotePath = $"/data/local/tmp/{executableName}"; + if (!adb.Push (hostSource, remotePath).Result) { + log.ErrorLine ($"Failed to upload debug server {hostSource} to device path {remotePath}"); + return false; + } + + // Next, copy it to the app dir, with run-as + (bool success, string output) = adb.Shell ( + "cat", remotePath, "|", + "run-as", packageName, + "sh", "-c", $"'cat > {deviceDestination}'" + ).Result; + + if (!success) { + log.ErrorLine ($"Failed to copy debug executable to device, from {hostSource} to {deviceDestination}"); + return false; + } + + (success, output) = adb.RunAs (packageName, "chmod", "700", deviceDestination).Result; + if (!success) { + log.ErrorLine ($"Failed to make debug server executable on device, at {deviceDestination}"); + return false; + } + + return true; + } + + // TODO: handle multiple pids + bool KillDebugServer (string debugServerPath) + { + long serverPID = GetDeviceProcessID (debugServerPath, quiet: false); + if (serverPID <= 0) { + return true; + } + + log.DebugLine ("Killing previous instance of the debug server"); + (bool success, string _) = adb.RunAs (packageName, "kill", "-9", $"{serverPID}").Result; + return success; + } + + long GetDeviceProcessID (string processName, bool quiet = false) + { + (bool success, string output) = adb.Shell ("pidof", Path.GetFileName (processName)).Result; + if (!success) { + if (!quiet) { + log.ErrorLine ($"Failed to obtain PID of process '{processName}'"); + log.ErrorLine (output); + } + return -1; + } + + output = output.Trim (); + if (!UInt32.TryParse (output, out uint pid)) { + if (!quiet) { + log.ErrorLine ($"Unable to parse string '{output}' as the package's PID"); + } + return -1; + } + + return pid; + } + + bool DetectTools () + { + // Not all versions of Android have the `which` utility, all of them have `whence` + // Also, API 21 adbd will not return an error code to us... But since we know that 21 + // doesn't have LDD, we'll cheat + deviceLdd = null; + if (apiLevel > 21) { + (bool success, string output) = adb.Shell ("whence", "ldd").Result; + if (success) { + log.DebugLine ($"Found `ldd` on device at '{output}'"); + deviceLdd = output; + } + } + + if (String.IsNullOrEmpty (deviceLdd)) { + log.DebugLine ("`ldd` not found on device"); + } + + return true; + } + + bool DetermineAppDataDirectory () + { + (bool success, string output) = adb.GetAppDataDirectory (packageName).Result; + if (!AppDataDirFound (success, output)) { + log.ErrorLine ($"Unable to determine data directory for package '{packageName}'"); + return false; + } + + appDataDir = output.Trim (); + log.StatusLine ($"Application data directory on device", appDataDir); + + appLldbBaseDir = $"{appDataDir}/lldb"; + appLldbBinDir = $"{appLldbBaseDir}/bin"; + appLldbLogDir = $"{appLldbBaseDir}/log"; + appLldbTmpDir = $"{appLldbBaseDir}/tmp"; + + // Applications with minSdkVersion >= 24 will have their data directories + // created with rwx------ permissions, preventing adbd from forwarding to + // the gdbserver socket. To be safe, if we're on a device >= 24, always + // chmod the directory. + if (apiLevel >= 24) { + (success, output) = adb.RunAs (packageName, "/system/bin/chmod", "a+x", appDataDir).Result; + if (!success) { + log.ErrorLine ("Failed to make application data directory world executable"); + return false; + } + } + + return true; + + bool AppDataDirFound (bool success, string output) + { + if (apiLevel > 21) { + return success; + } + + if (output.IndexOf ("run-as: Package", StringComparison.OrdinalIgnoreCase) >= 0 && + output.IndexOf ("is unknown", StringComparison.OrdinalIgnoreCase) >= 0) + { + return false; + } + + return true; + } + } + + bool DetermineArchitectureAndABI () + { + string? propValue = GetFirstFoundPropertyValue (abiProperties); + string[]? deviceABIs = propValue?.Split (','); + + if (deviceABIs == null || deviceABIs.Length == 0) { + log.ErrorLine ("Unable to determine device ABI"); + return false; + } + + LogABIs ("Application", supportedAbis); + LogABIs (" Device", deviceABIs); + + bool gotValidAbi = false; + var possibleAbis = new List (); + var possibleArches = new List (); + + foreach (string deviceABI in deviceABIs) { + foreach (string appABI in supportedAbis) { + if (String.Compare (appABI, deviceABI, StringComparison.OrdinalIgnoreCase) == 0) { + string arch = AbiToArch (deviceABI); + + if (!gotValidAbi) { + mainAbi = deviceABI; + mainArch = arch; + + log.StatusLine ($" Selected ABI", $"{mainAbi} (architecture: {mainArch})"); + + appIs64Bit = mainAbi.IndexOf ("64", StringComparison.Ordinal) >= 0; + gotValidAbi = true; + } + + possibleAbis.Add (deviceABI); + possibleArches.Add (arch); + } + } + } + + if (!gotValidAbi) { + log.ErrorLine ($"Application cannot run on the selected device: no matching ABI found"); + } + + availableAbis = possibleAbis.ToArray (); + availableArches = possibleArches.ToArray (); + return gotValidAbi; + + void LogABIs (string which, string[] abis) + { + log.StatusLine ($"{which} ABIs", String.Join (", ", abis)); + } + + string AbiToArch (string abi) => abi switch { + "armeabi" => "arm", + "armeabi-v7a" => "arm", + "arm64-v8a" => "arm64", + _ => abi, + }; + } + + string? GetFirstFoundPropertyValue (string[] propertyNames) + { + foreach (string prop in propertyNames) { + (bool success, string value) = adb.GetPropertyValue (prop).Result; + if (!success) { + continue; + } + + return value; + } + + return null; + } +} diff --git a/tools/debug-session-prep/Debug.Session.Prep/AndroidNdk.cs b/tools/debug-session-prep/Debug.Session.Prep/AndroidNdk.cs new file mode 100644 index 00000000000..48ec5dca23c --- /dev/null +++ b/tools/debug-session-prep/Debug.Session.Prep/AndroidNdk.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Xamarin.Android.Utilities; +using Xamarin.Android.Tasks; + +namespace Xamarin.Debug.Session.Prep; + +class AndroidNdk +{ + // We want the shell/batch scripts first, since they set up Python environment for the debugger + static readonly string[] lldbNames = { + "lldb.sh", + "lldb", + "lldb.cmd", + "lldb.exe", + }; + + Dictionary hostLldbServerPaths; + XamarinLoggingHelper log; + string? lldbPath; + + public string LldbPath => lldbPath ?? String.Empty; + + public AndroidNdk (XamarinLoggingHelper log, string ndkRootPath, string[] supportedAbis) + { + this.log = log; + hostLldbServerPaths = new Dictionary (StringComparer.Ordinal); + + if (!FindTools (ndkRootPath, supportedAbis)) { + throw new InvalidOperationException ("Failed to find all the required NDK tools"); + } + } + + public string? GetDebugServerPath (string abi) + { + if (!hostLldbServerPaths.TryGetValue (abi, out string? debugServerPath) || String.IsNullOrEmpty (debugServerPath)) { + log.ErrorLine ($"Debug server for abi '{abi}' not found."); + return null; + } + + return debugServerPath; + } + + bool FindTools (string ndkRootPath, string[] supportedAbis) + { + string toolchainDir = Path.Combine (ndkRootPath, NdkHelper.RelativeToolchainDir); + string toolchainBinDir = Path.Combine (toolchainDir, "bin"); + string? path = null; + + foreach (string lldb in lldbNames) { + path = Path.Combine (toolchainBinDir, lldb); + if (File.Exists (path)) { + break; + } + } + + if (String.IsNullOrEmpty (path)) { + log.ErrorLine ($"Unable to locate lldb executable in '{toolchainBinDir}'"); + return false; + } + lldbPath = path; + + hostLldbServerPaths = new Dictionary (StringComparer.OrdinalIgnoreCase); + string llvmVersion = GetLlvmVersion (toolchainDir); + foreach (string abi in supportedAbis) { + string llvmAbi = NdkHelper.TranslateAbiToLLVM (abi); + path = Path.Combine (ndkRootPath, NdkHelper.RelativeToolchainDir, "lib64", "clang", llvmVersion, "lib", "linux", llvmAbi, "lldb-server"); + if (!File.Exists (path)) { + log.ErrorLine ($"LLVM lldb server component for ABI '{abi}' not found at '{path}'"); + return false; + } + + hostLldbServerPaths.Add (abi, path); + } + + if (hostLldbServerPaths.Count == 0) { + log.ErrorLine ("Unable to find any lldb-server executables, debugging not possible"); + return false; + } + + return true; + } + + string GetLlvmVersion (string toolchainDir) + { + string path = Path.Combine (toolchainDir, "AndroidVersion.txt"); + if (!File.Exists (path)) { + throw new InvalidOperationException ($"LLVM version file not found at '{path}'"); + } + + string[] lines = File.ReadAllLines (path); + string? line = lines.Length >= 1 ? lines[0].Trim () : null; + if (String.IsNullOrEmpty (line)) { + throw new InvalidOperationException ($"Unable to read LLVM version from '{path}'"); + } + + return line; + } +} diff --git a/tools/debug-session-prep/Debug.Session.Prep/DeviceLibrariesCopier.cs b/tools/debug-session-prep/Debug.Session.Prep/DeviceLibrariesCopier.cs new file mode 100644 index 00000000000..96a793a08aa --- /dev/null +++ b/tools/debug-session-prep/Debug.Session.Prep/DeviceLibrariesCopier.cs @@ -0,0 +1,66 @@ +using System; + +using Xamarin.Android.Utilities; +using Xamarin.Android.Tasks; + +namespace Xamarin.Debug.Session.Prep; + +abstract class DeviceLibraryCopier +{ + protected XamarinLoggingHelper Log { get; } + protected bool AppIs64Bit { get; } + protected string LocalDestinationDir { get; } + protected AdbRunner Adb { get; } + protected AndroidDevice Device { get; } + + protected DeviceLibraryCopier (XamarinLoggingHelper log, AdbRunner adb, bool appIs64Bit, string localDestinationDir, AndroidDevice device) + { + Log = log; + Adb = adb; + AppIs64Bit = appIs64Bit; + LocalDestinationDir = localDestinationDir; + Device = device; + } + + protected string? FetchZygote () + { + string zygotePath; + string destination; + + if (AppIs64Bit) { + zygotePath = "/system/bin/app_process64"; + destination = Utilities.MakeLocalPath (LocalDestinationDir, zygotePath); + + Utilities.MakeFileDirectory (destination); + if (!Adb.Pull (zygotePath, destination).Result) { + Log.ErrorLine ("Failed to copy 64-bit app_process64"); + return null; + } + } else { + // /system/bin/app_process is 32-bit on 32-bit devices, but a symlink to + // app_process64 on 64-bit. If we need the 32-bit version, try to pull + // app_process32, and if that fails, pull app_process. + destination = Utilities.MakeLocalPath (LocalDestinationDir, "/system/bin/app_process"); + string? source = "/system/bin/app_process32"; + + Utilities.MakeFileDirectory (destination); + if (!Adb.Pull (source, destination).Result) { + source = "/system/bin/app_process"; + if (!Adb.Pull (source, destination).Result) { + source = null; + } + } + + if (String.IsNullOrEmpty (source)) { + Log.ErrorLine ("Failed to copy 32-bit app_process"); + return null; + } + + zygotePath = destination; + } + + return zygotePath; + } + + public abstract bool Copy (out string? zygotePath); +} diff --git a/tools/debug-session-prep/Debug.Session.Prep/LdConfigParser.cs b/tools/debug-session-prep/Debug.Session.Prep/LdConfigParser.cs new file mode 100644 index 00000000000..71b33086520 --- /dev/null +++ b/tools/debug-session-prep/Debug.Session.Prep/LdConfigParser.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Xamarin.Android.Utilities; + +namespace Xamarin.Debug.Session.Prep; + +class LdConfigParser +{ + XamarinLoggingHelper log; + + public LdConfigParser (XamarinLoggingHelper log) + { + this.log = log; + } + + // Format: https://android.googlesource.com/platform/bionic/+/master/linker/ld.config.format.md + // + public (List searchPaths, HashSet permittedPaths) Parse (string localLdConfigPath, string deviceBinDirectory, string libDirName) + { + var searchPaths = new List (); + var permittedPaths = new HashSet (); + bool foundSomeSection = false; + bool insideMatchingSection = false; + string normalizedDeviceBinDirectory = Utilities.NormalizeDirectoryPath (deviceBinDirectory); + string? sectionName = null; + + log.DebugLine ($"Parsing LD config file '{localLdConfigPath}'"); + int lineCounter = 0; + var namespaces = new List { + "default" + }; + + foreach (string l in File.ReadLines (localLdConfigPath)) { + lineCounter++; + string line = l.Trim (); + if (line.Length == 0 || line.StartsWith ('#')) { + continue; + } + + // The `dir.*` entries are before any section, don't waste time looking for them if we've parsed a section already + if (!foundSomeSection && sectionName == null) { + sectionName = GetMatchingDirMapping (normalizedDeviceBinDirectory, line); + if (sectionName != null) { + log.DebugLine ($"Found section name on line {lineCounter}: '{sectionName}'"); + continue; + } + } + + if (line[0] == '[') { + foundSomeSection = true; + insideMatchingSection = String.Compare (line, $"[{sectionName}]", StringComparison.Ordinal) == 0; + if (insideMatchingSection) { + log.DebugLine ($"Found section '{sectionName}' start on line {lineCounter}"); + } + } + + if (!insideMatchingSection) { + continue; + } + + if (line.StartsWith ("additional.namespaces", StringComparison.Ordinal) && GetVariableAssignmentParts (line, out string? name, out string? value)) { + foreach (string v in value!.Split (',')) { + string nsName = v.Trim (); + if (nsName.Length == 0) { + continue; + } + + log.DebugLine ($"Adding additional namespace '{nsName}'"); + namespaces.Add (nsName); + } + continue; + } + + MaybeAddLibraryPath (searchPaths, permittedPaths, namespaces, line, libDirName); + } + + return (searchPaths, permittedPaths); + + } + + void MaybeAddLibraryPath (List searchPaths, HashSet permittedPaths, List knownNamespaces, string configLine, string libDirName) + { + if (!configLine.StartsWith ("namespace.", StringComparison.Ordinal)) { + return; + } + + // not interested in ASAN libraries + if (configLine.IndexOf (".asan.", StringComparison.Ordinal) > 0) { + return; + } + + foreach (string ns in knownNamespaces) { + if (!GetVariableAssignmentParts (configLine, out string? name, out string? value)) { + continue; + } + + string varName = $"namespace.{ns}.search.paths"; + if (String.Compare (varName, name, StringComparison.Ordinal) == 0) { + AddPath (searchPaths, "search", value!); + continue; + } + + varName = $"namespace.{ns}.permitted.paths"; + if (String.Compare (varName, name, StringComparison.Ordinal) == 0) { + AddPath (permittedPaths, "permitted", value!, checkIfAlreadyAdded: true); + } + } + + void AddPath (ICollection list, string which, string value, bool checkIfAlreadyAdded = false) + { + string path = Utilities.NormalizeDirectoryPath (value.Replace ("${LIB}", libDirName)); + + if (checkIfAlreadyAdded && list.Contains (path)) { + return; + } + + log.DebugLine ($"Adding library {which} path: {path}"); + list.Add (path); + } + } + + string? GetMatchingDirMapping (string deviceBinDirectory, string configLine) + { + const string LinePrefix = "dir."; + + string line = configLine.Trim (); + if (line.Length == 0 || !line.StartsWith (LinePrefix, StringComparison.Ordinal)) { + return null; + } + + if (!GetVariableAssignmentParts (line, out string? name, out string? value)) { + return null; + } + + string dirPath = Utilities.NormalizeDirectoryPath (value!); + if (String.Compare (dirPath, deviceBinDirectory, StringComparison.Ordinal) != 0) { + return null; + } + + string ns = name!.Substring (LinePrefix.Length).Trim (); + if (String.IsNullOrEmpty (ns)) { + return null; + } + + return ns; + } + + bool GetVariableAssignmentParts (string line, out string? name, out string? value) + { + name = value = null; + + string[] parts = line.Split ("+=", 2); + if (parts.Length != 2) { + parts = line.Split ('=', 2); + if (parts.Length != 2) { + return false; + } + } + + name = parts[0].Trim (); + value = parts[1].Trim (); + + return true; + } +} diff --git a/tools/debug-session-prep/Debug.Session.Prep/LddDeviceLibrariesCopier.cs b/tools/debug-session-prep/Debug.Session.Prep/LddDeviceLibrariesCopier.cs new file mode 100644 index 00000000000..ea7216399cc --- /dev/null +++ b/tools/debug-session-prep/Debug.Session.Prep/LddDeviceLibrariesCopier.cs @@ -0,0 +1,18 @@ +using System; + +using Xamarin.Android.Utilities; +using Xamarin.Android.Tasks; + +namespace Xamarin.Debug.Session.Prep; + +class LddDeviceLibraryCopier : DeviceLibraryCopier +{ + public LddDeviceLibraryCopier (XamarinLoggingHelper log, AdbRunner adb, bool appIs64Bit, string localDestinationDir, AndroidDevice device) + : base (log, adb, appIs64Bit, localDestinationDir, device) + {} + + public override bool Copy (out string? zygotePath) + { + throw new NotImplementedException(); + } +} diff --git a/tools/debug-session-prep/Debug.Session.Prep/LldbModuleCache.cs b/tools/debug-session-prep/Debug.Session.Prep/LldbModuleCache.cs new file mode 100644 index 00000000000..89a9b4cfcf3 --- /dev/null +++ b/tools/debug-session-prep/Debug.Session.Prep/LldbModuleCache.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using ELFSharp.ELF; +using ELFSharp.ELF.Sections; +using Xamarin.Android.Utilities; + +namespace Xamarin.Debug.Session.Prep; + +abstract class LldbModuleCache +{ + protected AndroidDevice Device { get; } + protected string CacheDirPath { get; } + protected XamarinLoggingHelper Log { get; } + + protected LldbModuleCache (XamarinLoggingHelper log, AndroidDevice device) + { + Device = device; + Log = log; + + CacheDirPath = Path.Combine ( + Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), + ".lldb", + "module_cache", + "remote-android", // "platform" used by LLDB in our case + device.SerialNumber + ); + } + + public void Populate (string zygotePath) + { + string? localPath = FetchFileFromDevice (zygotePath); + if (localPath == null) { + // TODO: should we perhaps fetch a set of "basic" libraries here, as a fallback? + Log.WarningLine ($"Unable to fetch Android application launcher binary ('{zygotePath}') from device. No cache of shared modules will be generated"); + return; + } + + var alreadyDownloaded = new HashSet (StringComparer.Ordinal); + using IELF? elf = ReadElfFile (localPath); + + FetchDependencies (elf, alreadyDownloaded, localPath); + } + + void FetchDependencies (IELF? elf, HashSet alreadyDownloaded, string localPath) + { + if (elf == null) { + Log.DebugLine ($"Failed to open '{localPath}' as an ELF file. Ignoring."); + return; + } + + var dynstr = GetSection (elf, ".dynstr") as IStringTable; + if (dynstr == null) { + Log.DebugLine ($"ELF binary {localPath} has no .dynstr section, unable to read referenced shared library names"); + return; + } + + var needed = new HashSet (StringComparer.Ordinal); + foreach (IDynamicSection section in elf.GetSections ()) { + foreach (IDynamicEntry entry in section.Entries) { + if (entry.Tag != DynamicTag.Needed) { + continue; + } + + AddNeeded (dynstr, entry); + } + } + + Log.DebugLine ($"Binary {localPath} references the following libraries:"); + foreach (string lib in needed) { + Log.Debug ($" {lib}"); + if (alreadyDownloaded.Contains (lib)) { + Log.DebugLine (" [already downloaded]"); + continue; + } + + string? deviceLibraryPath = GetSharedLibraryPath (lib); + if (String.IsNullOrEmpty (deviceLibraryPath)) { + Log.DebugLine (" [device path unknown]"); + Log.WarningLine ($"Referenced libary '{lib}' not found on device"); + continue; + } + + Log.DebugLine (" [downloading]"); + Log.Status ("Downloading", deviceLibraryPath); + string? localLibraryPath = FetchFileFromDevice (deviceLibraryPath); + if (String.IsNullOrEmpty (localLibraryPath)) { + Log.Log (LogLevel.Info, " [FAILED]", XamarinLoggingHelper.ErrorColor); + continue; + } + Log.LogLine (LogLevel.Info, " [SUCCESS]", XamarinLoggingHelper.InfoColor); + + alreadyDownloaded.Add (lib); + using IELF? libElf = ReadElfFile (localLibraryPath); + FetchDependencies (libElf, alreadyDownloaded, localLibraryPath); + } + + void AddNeeded (IStringTable stringTable, IDynamicEntry entry) + { + ulong index; + if (entry is DynamicEntry entry64) { + index = entry64.Value; + } else if (entry is DynamicEntry entry32) { + index = (ulong)entry32.Value; + } else { + Log.WarningLine ($"DynamicEntry neither 32 nor 64 bit? Weird"); + return; + } + + string name = stringTable[(long)index]; + if (needed.Contains (name)) { + return; + } + + needed.Add (name); + } + } + + string? FetchFileFromDevice (string deviceFilePath) + { + string localFilePath = Utilities.MakeLocalPath (CacheDirPath, deviceFilePath); + string localTempFilePath = $"{localFilePath}.tmp"; + + Directory.CreateDirectory (Path.GetDirectoryName (localFilePath)!); + + if (!Device.AdbRunner.Pull (deviceFilePath, localTempFilePath).Result) { + Log.ErrorLine ($"Failed to download {deviceFilePath} from the attached device"); + return null; + } + + File.Move (localTempFilePath, localFilePath, true); + return localFilePath; + } + + protected string GetUnixFileName (string path) + { + int idx = path.LastIndexOf ('/'); + if (idx >= 0 && idx != path.Length - 1) { + return path.Substring (idx + 1); + } + + return path; + } + + protected abstract string? GetSharedLibraryPath (string libraryName); + + IELF? ReadElfFile (string path) + { + try { + if (ELFReader.TryLoad (path, out IELF ret)) { + return ret; + } + } catch (Exception ex) { + Log.WarningLine ($"{path} may not be a valid ELF binary."); + Log.WarningLine (ex.ToString ()); + } + + return null; + } + + ISection? GetSection (IELF elf, string sectionName) + { + if (!elf.TryGetSection (sectionName, out ISection section)) { + return null; + } + + return section; + } +} diff --git a/tools/debug-session-prep/Debug.Session.Prep/NoLddDeviceLibrariesCopier.cs b/tools/debug-session-prep/Debug.Session.Prep/NoLddDeviceLibrariesCopier.cs new file mode 100644 index 00000000000..a33f14917a2 --- /dev/null +++ b/tools/debug-session-prep/Debug.Session.Prep/NoLddDeviceLibrariesCopier.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Xamarin.Android.Utilities; +using Xamarin.Android.Tasks; + +namespace Xamarin.Debug.Session.Prep; + +class NoLddDeviceLibraryCopier : DeviceLibraryCopier +{ + const string LdConfigPath = "/system/etc/ld.config.txt"; + + // To make things interesting, it turns out that API29 devices have **both** API 28 and 29 (they report 28) and for that reason they have TWO config files for ld... + const string LdConfigPath28 = "/etc/ld.config.28.txt"; + const string LdConfigPath29 = "/etc/ld.config.29.txt"; + + // TODO: We probably need a "provider" for the list of paths, since on ARM devices, /system/lib{64} directories contain x86/x64 binaries, and the ARM binaries are found in + // /system/lib{64]/arm{64} (but not on all devices, of course... e.g. Pixel 6 Pro doesn't have these) + // + // List of directory paths to use when the device has neither ldd nor /system/etc/ld.config.txt + static readonly string[] FallbackLibraryDirectories = { + "/system/@LIB@", + "/system/@LIB@/drm", + "/system/@LIB@/egl", + "/system/@LIB@/hw", + "/system/@LIB@/soundfx", + "/system/@LIB@/ssl", + "/system/@LIB@/ssl/engines", + + // /system/vendor is a symlink to /vendor on some Android versions, we'll skip the latter then + "/system/vendor/@LIB@", + "/system/vendor/@LIB@/egl", + "/system/vendor/@LIB@/mediadrm", + }; + + public NoLddDeviceLibraryCopier (XamarinLoggingHelper log, AdbRunner adb, bool appIs64Bit, string localDestinationDir, AndroidDevice device) + : base (log, adb, appIs64Bit, localDestinationDir, device) + {} + + public override bool Copy (out string? zygotePath) + { + zygotePath = FetchZygote (); + if (String.IsNullOrEmpty (zygotePath)) { + Log.ErrorLine ("Unable to determine path of the zygote process on device"); + return false; + } + + (List searchPaths, HashSet permittedPaths) = GetLibraryPaths (); + + // Collect file listings for all the search directories + var sharedLibraries = new List (); + foreach (string path in searchPaths) { + AddSharedLibraries (sharedLibraries, path, permittedPaths); + } + + var moduleCache = new NoLddLldbModuleCache (Log, Device, sharedLibraries); + moduleCache.Populate (zygotePath); + + return true; + } + + void AddSharedLibraries (List sharedLibraries, string deviceDirPath, HashSet permittedPaths) + { + AdbRunner.OutputLineFilter filterOutErrors = (bool isStdError, string line) => { + if (!isStdError) { + return false; // don't suppress any lines on stdout + } + + // Ignore these, since we don't really care and there's no point in spamming the output with red + return + line.IndexOf ("Permission denied", StringComparison.OrdinalIgnoreCase) >= 0 || + line.IndexOf ("No such file or directory", StringComparison.OrdinalIgnoreCase) >= 0; + }; + + (bool success, string output) = Adb.Shell (filterOutErrors, "ls", "-l", deviceDirPath).Result; + if (!success) { + // We can't rely on `success` because `ls -l` will return an error code if the directory exists but has any entries access to whose is not permitted + if (output.IndexOf ("No such file or directory", StringComparison.OrdinalIgnoreCase) >= 0) { + Log.DebugLine ($"Shared libraries directory {deviceDirPath} not found on device"); + return; + } + } + + Log.DebugLine ($"Adding shared libraries from {deviceDirPath}"); + foreach (string l in output.Split ('\n')) { + string line = l.Trim (); + if (line.Length == 0) { + continue; + } + + // `ls -l` output has 8 columns for filesystem entries + string[] parts = line.Split (' ', 8, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 8) { + continue; + } + + string permissions = parts[0].Trim (); + string name = parts[7].Trim (); + + // First column, permissions: `drwxr-xr-x`, `-rw-r--r--` etc + if (permissions[0] == 'd') { + // Directory + string nestedDirPath = $"{deviceDirPath}{name}/"; + if (permittedPaths.Count > 0 && !permittedPaths.Contains (nestedDirPath)) { + Log.DebugLine ($"Directory '{nestedDirPath}' is not in the list of permitted directories, ignoring"); + continue; + } + + AddSharedLibraries (sharedLibraries, nestedDirPath, permittedPaths); + continue; + } + + // Ignore entries that aren't regular .so files or symlinks + if ((permissions[0] != '-' && permissions[0] != 'l') || !name.EndsWith (".so", StringComparison.Ordinal)) { + continue; + } + + string libPath; + if (permissions[0] == 'l') { + // Let's hope there are no libraries with -> in their name :P (if there are, we should use `readlink`) + const string SymlinkArrow = "->"; + + // Symlink, we'll add the target library instead + int idx = name.IndexOf (SymlinkArrow, StringComparison.Ordinal); + if (idx > 0) { + libPath = name.Substring (idx + SymlinkArrow.Length).Trim (); + } else { + Log.WarningLine ($"'ls -l' output line contains a symbolic link, but I can't determine the target:"); + Log.WarningLine ($" '{line}'"); + Log.WarningLine ("Ignoring this entry"); + continue; + } + } else { + libPath = $"{deviceDirPath}{name}"; + } + + Log.DebugLine ($" {libPath}"); + sharedLibraries.Add (libPath); + } + } + + (List searchPaths, HashSet permittedPaths) GetLibraryPaths () + { + string lib = AppIs64Bit ? "lib64" : "lib"; + + if (Device.ApiLevel == 21) { + // API21 devices (at least emulators) don't return adb error codes, so to avoid awkward error message parsing, we're going to just skip detection since we + // know what API21 has and doesn't have + return (GetFallbackDirs (), new HashSet ()); + } + + string localLdConfigPath = Utilities.MakeLocalPath (LocalDestinationDir, LdConfigPath); + Utilities.MakeFileDirectory (localLdConfigPath); + + string deviceLdConfigPath; + + if (Device.ApiLevel == 28) { + deviceLdConfigPath = LdConfigPath28; + } else if (Device.ApiLevel == 29) { + deviceLdConfigPath = LdConfigPath29; + } else { + deviceLdConfigPath = LdConfigPath; + } + + if (!Adb.Pull (deviceLdConfigPath, localLdConfigPath).Result) { + Log.DebugLine ($"Device doesn't have {LdConfigPath}"); + return (GetFallbackDirs (), new HashSet ()); + } else { + Log.DebugLine ($"Downloaded {deviceLdConfigPath} to {localLdConfigPath}"); + } + + var parser = new LdConfigParser (Log); + + // The app executables (app_process and app_process32) are both in /system/bin, so we can limit our + // library search paths to this location. + (List searchPaths, HashSet permittedPaths) = parser.Parse (localLdConfigPath, "/system/bin", lib); + if (searchPaths.Count == 0) { + searchPaths = GetFallbackDirs (); + } + + return (searchPaths, permittedPaths); + + List GetFallbackDirs () + { + Log.DebugLine ("Using fallback library directories for this device"); + return FallbackLibraryDirectories.Select (l => l.Replace ("@LIB@", lib)).ToList (); + } + } +} diff --git a/tools/debug-session-prep/Debug.Session.Prep/NoLddLldbModuleCache.cs b/tools/debug-session-prep/Debug.Session.Prep/NoLddLldbModuleCache.cs new file mode 100644 index 00000000000..ca22fdf332d --- /dev/null +++ b/tools/debug-session-prep/Debug.Session.Prep/NoLddLldbModuleCache.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +using Xamarin.Android.Utilities; + +namespace Xamarin.Debug.Session.Prep; + +class NoLddLldbModuleCache : LldbModuleCache +{ + List deviceSharedLibraries; + Dictionary libraryCache; + + public NoLddLldbModuleCache (XamarinLoggingHelper log, AndroidDevice device, List deviceSharedLibraries) + : base (log, device) + { + this.deviceSharedLibraries = deviceSharedLibraries; + libraryCache = new Dictionary (StringComparer.Ordinal); + } + + protected override string? GetSharedLibraryPath (string libraryName) + { + if (libraryCache.TryGetValue (libraryName, out string? libraryPath)) { + return libraryPath; + } + + // List is sorted on the order of directories as specified by ld.config.txt, file entries aren't + // sorted inside. + foreach (string libPath in deviceSharedLibraries) { + string fileName = GetUnixFileName (libPath); + + if (String.Compare (libraryName, fileName, StringComparison.Ordinal) == 0) { + libraryCache.Add (libraryName, libPath); + return libPath; + } + } + + // Cache misses, too, the list isn't going to change + libraryCache.Add (libraryName, null); + return null; + } +} diff --git a/tools/debug-session-prep/Debug.Session.Prep/Utilities.cs b/tools/debug-session-prep/Debug.Session.Prep/Utilities.cs new file mode 100644 index 00000000000..5ff6f08075c --- /dev/null +++ b/tools/debug-session-prep/Debug.Session.Prep/Utilities.cs @@ -0,0 +1,78 @@ +using System; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; + +using Xamarin.Android.Utilities; + +namespace Xamarin.Debug.Session.Prep; + +static class Utilities +{ + public static readonly UTF8Encoding UTF8NoBOM = new UTF8Encoding (false); + + public static bool IsMacOS { get; private set; } + public static bool IsLinux { get; private set; } + public static bool IsWindows { get; private set; } + + static Utilities () + { + if (RuntimeInformation.IsOSPlatform (OSPlatform.Windows)) { + IsWindows = true; + } else if (RuntimeInformation.IsOSPlatform (OSPlatform.OSX)) { + IsMacOS = true; + } else if (RuntimeInformation.IsOSPlatform (OSPlatform.Linux)) { + IsLinux = true; + } + } + + public static void MakeFileDirectory (string filePath) + { + if (String.IsNullOrEmpty (filePath)) { + return; + } + + string? dirName = Path.GetDirectoryName (filePath); + if (String.IsNullOrEmpty (dirName)) { + return; + } + + Directory.CreateDirectory (dirName); + } + + public static string? ReadManifestResource (XamarinLoggingHelper log, string resourceName) + { + using (var from = Assembly.GetExecutingAssembly ().GetManifestResourceStream (resourceName)) { + if (from == null) { + log.ErrorLine ($"Manifest resource '{resourceName}' cannot be loaded"); + return null; + } + + using (var sr = new StreamReader (from)) { + return sr.ReadToEnd (); + } + } + } + + public static string NormalizeDirectoryPath (string dirPath) + { + if (dirPath.EndsWith ('/')) { + return dirPath; + } + + return $"{dirPath}/"; + } + + public static string ToLocalPathFormat (string path) => IsWindows ? path.Replace ("/", "\\") : path; + + public static string MakeLocalPath (string localDirectory, string remotePath) + { + string remotePathLocalFormat = ToLocalPathFormat (remotePath); + if (remotePath[0] == '/') { + return $"{localDirectory}{remotePathLocalFormat}"; + } + + return Path.Combine (localDirectory, remotePathLocalFormat); + } +} diff --git a/tools/debug-session-prep/Main.cs b/tools/debug-session-prep/Main.cs new file mode 100644 index 00000000000..e3f5e5714c9 --- /dev/null +++ b/tools/debug-session-prep/Main.cs @@ -0,0 +1,290 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +using Mono.Options; +using Xamarin.Android.Utilities; + +namespace Xamarin.Debug.Session.Prep; + +class App +{ + const string DefaultAdbPath = "adb"; + + static readonly Dictionary SupportedAbiMap = new Dictionary (StringComparer.OrdinalIgnoreCase) { + {"arm32", "armeabi-v7a"}, + {"arm64", "arm64-v8a"}, + {"arm64-v8a", "arm64-v8a"}, + {"armeabi", "armeabi-v7a"}, + {"armeabi-v7a", "armeabi-v7a"}, + {"x86", "x86"}, + {"x86_64", "x86_64"}, + {"x64", "x86_64"} + }; + + sealed class ParsedOptions + { + public string? AdbPath; + public string? PackageName; + public string[]? SupportedABIs; + public string? TargetDevice; + public bool ShowHelp; + public bool Verbose = true; // TODO: remove the default once development is done + public string? AppNativeLibrariesDir; + public string? NdkDirPath; + public string? OutputDirPath; + public string? ConfigScriptName; + public string? LldbScriptName; + } + + static int Main (string[] args) + { + bool haveOptionErrors = false; + var parsedOptions = new ParsedOptions (); + var log = new XamarinLoggingHelper { + Verbose = parsedOptions.Verbose, + }; + + var opts = new OptionSet { + "Usage: debug-session-prep [REQUIRED_OPTIONS] [OPTIONS]", + "", + "REQUIRED_OPTIONS are:", + { "p|package-name=", "name of the application package", v => parsedOptions.PackageName = EnsureNonEmptyString (log, "-p|--package-name", v, ref haveOptionErrors) }, + { "s|supported-abis=", "comma-separated list of ABIs the application supports", v => parsedOptions.SupportedABIs = EnsureSupportedABIs (log, "-s|--supported-abis", v, ref haveOptionErrors) }, + { "l|lib-dir=", "{PATH} to the directory where application native libraries were copied (relative to output directory, below)", v => parsedOptions.AppNativeLibrariesDir = v }, + { "n|ndk-dir=", "{PATH} to to the Android NDK root directory", v => parsedOptions.NdkDirPath = v }, + { "o|output-dir=", "{PATH} to directory which will contain various generated files (logs, scripts etc)", v => parsedOptions.OutputDirPath = v }, + { "c|config-script=", "{NAME} of the launcher configuration script which will be created in the output directory", v => parsedOptions.ConfigScriptName = v }, + { "g|lldb-script=", "{NAME} of the LLDB script which will be created in the output directory", v => parsedOptions.LldbScriptName = v }, + "", + "OPTIONS are:", + { "a|adb=", "{PATH} to adb to use for this session", v => parsedOptions.AdbPath = EnsureNonEmptyString (log, "-a|--adb", v, ref haveOptionErrors) }, + { "d|device=", "ID of {DEVICE} to target for this session", v => parsedOptions.TargetDevice = EnsureNonEmptyString (log, "-d|--device", v, ref haveOptionErrors) }, + "", + { "v|verbose", "Show debug messages", v => parsedOptions.Verbose = true }, + { "h|help|?", "Show this help screen", v => parsedOptions.ShowHelp = true }, + "", + $"Supported ABI names are: {GetSupportedAbiNames ()}", + "", + }; + + List rest = opts.Parse (args); + log.Verbose = parsedOptions.Verbose; + + if (parsedOptions.ShowHelp) { + opts.WriteOptionDescriptions (Console.Out); + return 0; + } + + if (haveOptionErrors) { + return 1; + } + + bool missingRequiredOptions = false; + if (parsedOptions.SupportedABIs == null || parsedOptions.SupportedABIs.Length == 0) { + log.ErrorLine ("The '-s|--supported-abis' option must be used to provide a non-empty list of Android ABIs supported by the application"); + missingRequiredOptions = true; + } + + if (String.IsNullOrEmpty (parsedOptions.PackageName)) { + log.ErrorLine ("The '-p|--package-name' option must be used to provide non-empty application package name"); + missingRequiredOptions = true; + } + + if (String.IsNullOrEmpty (parsedOptions.NdkDirPath)) { + log.ErrorLine ("The '-n|--ndk-dir' option must be used to specify the directory where Android NDK is installed"); + missingRequiredOptions = true; + } + + if (String.IsNullOrEmpty (parsedOptions.OutputDirPath)) { + log.ErrorLine ("The '-o|--output-dir' option must be used to specify the directory where generated files will be placed"); + missingRequiredOptions = true; + } + + if (String.IsNullOrEmpty (parsedOptions.AppNativeLibrariesDir)) { + log.ErrorLine ("The '-l|--lib-dir' option must be used to specify the directory where application shared libraries were copied"); + missingRequiredOptions = true; + } + + if (String.IsNullOrEmpty (parsedOptions.ConfigScriptName)) { + log.ErrorLine ("The '-c|--config-script' option must be used to specify name of the launcher configuration script"); + missingRequiredOptions = true; + } + + if (String.IsNullOrEmpty (parsedOptions.LldbScriptName)) { + log.ErrorLine ("The '-g|--lldb-script' option must be used to specify name of the LLDB script"); + missingRequiredOptions = true; + } + + if (missingRequiredOptions) { + return 1; + } + + var ndk = new AndroidNdk (log, parsedOptions.NdkDirPath!, parsedOptions.SupportedABIs!); + var device = new AndroidDevice ( + log, + ndk, + parsedOptions.OutputDirPath!, + parsedOptions.AdbPath ?? DefaultAdbPath, + parsedOptions.PackageName!, + parsedOptions.SupportedABIs!, + parsedOptions.TargetDevice + ); + + if (!device.GatherInfo ()) { + return 1; + } + + if (device.ApiLevel < 21) { + log.ErrorLine ($"Only Android API level 21 and newer are supported"); + return 1; + } + + if (!device.Prepare (out string? mainProcessPath) || String.IsNullOrEmpty (mainProcessPath)) { + log.ErrorLine ("Failed to prepare for debugging session"); + return 1; + } + + string socketScheme = "unix-abstract"; + string socketDir = $"/xa-{parsedOptions.PackageName}"; + + var rnd = new Random (); + string socketName = $"xa-platform-{rnd.NextInt64 ()}.sock"; + + WriteConfigScript (parsedOptions, device, ndk, socketScheme, socketDir, socketName); + WriteLldbScript (parsedOptions, device, socketScheme, socketDir, socketName, mainProcessPath); + + return 0; + } + + static FileStream OpenScriptStream (string path) + { + return File.Open (path, FileMode.Create, FileAccess.Write, FileShare.Read); + } + + static StreamWriter OpenScriptWriter (FileStream fs) + { + return new StreamWriter (fs, Utilities.UTF8NoBOM); + } + + static void WriteLldbScript (ParsedOptions parsedOptions, AndroidDevice device, string socketScheme, string socketDir, string socketName, string mainProcessPath) + { + string outputFile = Path.Combine (parsedOptions.OutputDirPath!, parsedOptions.LldbScriptName!); + string fullLibsDir = Path.GetFullPath (Path.Combine (parsedOptions.OutputDirPath!, parsedOptions.AppNativeLibrariesDir!)); + using FileStream fs = OpenScriptStream (outputFile); + using StreamWriter sw = OpenScriptWriter (fs); + + // TODO: add support for appending user commands + var searchPathsList = new List { + $"\"{Path.Combine (fullLibsDir, device.MainAbi)}\"" + }; + + foreach (string abi in device.AvailableAbis) { + if (String.Compare (abi, device.MainAbi, StringComparison.Ordinal) == 0) { + continue; + } + + searchPathsList.Add ($"\"{Path.Combine (fullLibsDir, abi)}\""); + } + + string searchPaths = String.Join (" ", searchPathsList); + sw.WriteLine ($"settings append target.exec-search-paths {searchPaths}"); + sw.WriteLine ("platform select remote-android"); + sw.WriteLine ($"platform connect {socketScheme}-connect://{socketDir}/{socketName}"); + sw.WriteLine ($"file \"{mainProcessPath}\""); + + sw.Flush (); + } + + static void WriteConfigScript (ParsedOptions parsedOptions, AndroidDevice device, AndroidNdk ndk, string socketScheme, string socketDir, string socketName) + { + bool powershell = Utilities.IsWindows; + string outputFile = Path.Combine (parsedOptions.OutputDirPath!, parsedOptions.ConfigScriptName!); + using FileStream fs = OpenScriptStream (outputFile); + using StreamWriter sw = OpenScriptWriter (fs); + + sw.WriteLine ($"DEVICE_API_LEVEL={device.ApiLevel}"); + sw.WriteLine ($"DEVICE_AVAILABLE_ABIS={FormatArray (device.AvailableAbis)}"); + sw.WriteLine ($"DEVICE_AVAILABLE_ARCHES={FormatArray (device.AvailableArches)}"); + sw.WriteLine ($"DEVICE_DEBUG_SERVER_LAUNCHER=\"{device.DebugServerLauncherScriptPath}\""); + sw.WriteLine ($"DEVICE_LLDB_DIR=\"{device.LldbBaseDir}\""); + sw.WriteLine ($"DEVICE_MAIN_ABI={device.MainAbi}"); + sw.WriteLine ($"DEVICE_MAIN_ARCH={device.MainArch}"); + sw.WriteLine ($"DEVICE_SERIAL=\"{device.SerialNumber}\""); + sw.WriteLine ($"LLDB_PATH=\"{ndk.LldbPath}\""); + sw.WriteLine ($"SOCKET_DIR={socketDir}"); + sw.WriteLine ($"SOCKET_NAME={socketName}"); + sw.WriteLine ($"SOCKET_SCHEME={socketScheme}"); + + sw.Flush (); + + string FormatArray (string[] values) + { + var sb = new StringBuilder (); + if (powershell) { + sb.Append ('@'); + } + sb.Append ('('); + + bool first = true; + foreach (string v in values) { + if (first) { + first = false; + } else { + sb.Append (powershell ? ", " : " "); + } + sb.Append ('"'); + sb.Append (v); + sb.Append ('"'); + } + + sb.Append (')'); + + return sb.ToString (); + } + } + + static string[]? EnsureSupportedABIs (XamarinLoggingHelper log, string paramName, string? value, ref bool haveOptionErrors) + { + string? abis = EnsureNonEmptyString (log, paramName, value, ref haveOptionErrors); + if (abis == null) { + return null; + } + + bool haveInvalidAbis = false; + var list = new List (); + foreach (string s in abis.Split (',')) { + string? abi = s?.Trim (); + if (String.IsNullOrEmpty (abi)) { + continue; + } + + if (!SupportedAbiMap.TryGetValue (abi, out string? mappedAbi) || String.IsNullOrEmpty (mappedAbi)) { + log.ErrorLine ($"Unsupported ABI: {abi}"); + haveInvalidAbis = true; + } + + list.Add (mappedAbi!); + } + + if (haveInvalidAbis) { + return null; + } + + return list.ToArray (); + } + + static string? EnsureNonEmptyString (XamarinLoggingHelper log, string paramName, string? value, ref bool haveOptionErrors) + { + if (String.IsNullOrEmpty (value)) { + haveOptionErrors = true; + log.ErrorLine ($"Parameter '{paramName}' requires a non-empty string as its value"); + return null; + } + + return value; + } + + static string GetSupportedAbiNames () => String.Join (", ", SupportedAbiMap.Keys); +} diff --git a/tools/debug-session-prep/Resources/lldb-debug-session.sh b/tools/debug-session-prep/Resources/lldb-debug-session.sh new file mode 100755 index 00000000000..b880d883e3d --- /dev/null +++ b/tools/debug-session-prep/Resources/lldb-debug-session.sh @@ -0,0 +1,159 @@ +#!/bin/bash + +# Passed on command line +ADB_DEVICE="" +ARCH="" + +# Values set by the preparation task +SESSION_LOG_DIR="." +SESSION_STDERR_LOG_FILE="" + +# Detected during run +DEVICE_API_LEVEL="" +DEVICE_ABI="" +DEVICE_ARCH="" + +# Constants +ABI_PROPERTIES=( + # new properties + "ro.product.cpu.abilist" + + # old properties + "ro.product.cpu.abi" + "ro.product.cpu.abi2" +) + +function die() +{ + echo "$@" >&2 + exit 1 +} + +function die_with_log() +{ + local log_file="${1}" + + shift + + echo "$@" >&2 + if [ -f "${log_file}" ]; then + echo >&2 + cat "${log_file}" >&2 + echo >&2 + fi + + exit 1 +} + +function run_adb_nocheck() +{ + local args="" + + if [ -n "${ADB_DEVICE}" ]; then + args="-s ${ADB_DEVICE}" + fi + + COMMAND_OUTPUT="$(adb ${args} "$@")" +} + +function run_adb() +{ + local command_stderr="${SESSION_LOG_DIR}/adb-cmd-stderr.log" + + run_adb_nocheck "$@" 2> "${command_stderr}" + if [ $? -ne 0 ]; then + cat "${command_stderr}" >> "${SESSION_STDERR_LOG_FILE}" + die_with_log "${command_stderr}" "ADB command failed: " adb "${args}" "$@" + fi +} + +function adb_shell() +{ + run_adb shell "$@" +} + +function adb_shell_nocheck() +{ + run_adb_nocheck shell "$@" +} + +function adb_get_property() +{ + adb_shell getprop "$@" +} + +function get_api_level() +{ + adb_get_property ro.build.version.sdk + if [ $? -ne 0 ]; then + die "Unable to determine API level of the connected device" + fi + DEVICE_API_LEVEL="${COMMAND_OUTPUT}" +} + +function property_is_equal_to() +{ + local prop_name="${1}" + local expected_value="${2}" + + local prop_value + adb_get_property "${prop_name}" + prop_value=${COMMAND_OUTPUT} + + if [ -z "${prop_value}" -o "${prop_value}" != "${expected_value}" ]; then + false + return + fi + + true +} + +function warn_old_pixel_c() +{ + adb_shell_nocheck cat /proc/sys/kernel/yama/ptrace_scope "2> /dev/null" + if [ $? -ne 0 ]; then + true + return + fi + + local yama=${COMMAND_OUTPUT} + if [ -z "${yama}" -o "${yama}" == "0" ]; then + true + return + fi + + local prop_value + adb_get_property ro.build.product + prop_value=${COMMAND_OUTPUT} + + if ! property_is_equal_to "ro.build.product" "dragon"; then + true + return + fi + + if ! property_is_equal_to "ro.product.name" "ryu"; then + true + return + fi + + cat <&2 + +WARNING: The device uses Yama ptrace_scope to restrict debugging. ndk-gdb will + likely be unable to attach to a process. With root access, the restriction + can be lifted by writing 0 to /proc/sys/kernel/yama/ptrace_scope. Consider + upgrading your Pixel C to MXC89L or newer, where Yama is disabled. + +EOF +} + +if [ ! -d "${SESSION_LOG_DIR}" ]; then + install -d -m 755 "${SESSION_LOG_DIR}" +fi + +SESSION_STDERR_LOG_FILE="${SESSION_LOG_DIR}/adb-stderr.log" +rm -f "${SESSION_STDERR_LOG_FILE}" + +warn_old_pixel_c +get_api_level + +echo API: ${DEVICE_API_LEVEL} diff --git a/tools/debug-session-prep/Resources/xa_start_lldb_server.sh b/tools/debug-session-prep/Resources/xa_start_lldb_server.sh new file mode 100755 index 00000000000..8fa6cdb3141 --- /dev/null +++ b/tools/debug-session-prep/Resources/xa_start_lldb_server.sh @@ -0,0 +1,41 @@ +#!/system/bin/sh + +# This script launches lldb-server on Android device from application subfolder - /data/data/$packageId/lldb/bin. +# Native run configuration is expected to push this script along with lldb-server to the device prior to its execution. +# Following command arguments are expected to be passed - lldb package directory and lldb-server listen port. +set -x +umask 0002 + +LLDB_DIR=$1 +LISTENER_SCHEME=$2 +DOMAINSOCKET_DIR=$3 +PLATFORM_SOCKET=$4 +LOG_CHANNELS="$5" + +BIN_DIR=$LLDB_DIR/bin +LOG_DIR=$LLDB_DIR/log +TMP_DIR=$LLDB_DIR/tmp +PLATFORM_LOG_FILE=$LOG_DIR/platform.log + +export LLDB_DEBUGSERVER_LOG_FILE=$LOG_DIR/gdb-server.log +export LLDB_SERVER_LOG_CHANNELS="$LOG_CHANNELS" +export LLDB_DEBUGSERVER_DOMAINSOCKET_DIR=$DOMAINSOCKET_DIR + +# This directory already exists. Make sure it has the right permissions. +chmod 0775 "$LLDB_DIR" + +rm -r $TMP_DIR +mkdir $TMP_DIR +export TMPDIR=$TMP_DIR + +rm -r $LOG_DIR +mkdir $LOG_DIR + +# LLDB would create these files with more restrictive permissions than our umask above. Make sure +# it doesn't get a chance. +# "touch" does not exist on pre API-16 devices. This is a poor man's replacement +cat "$LLDB_DEBUGSERVER_LOG_FILE" 2>"$PLATFORM_LOG_FILE" + +cd $TMP_DIR # change cwd + +$BIN_DIR/lldb-server platform --server --listen $LISTENER_SCHEME://$DOMAINSOCKET_DIR/$PLATFORM_SOCKET --log-file "$PLATFORM_LOG_FILE" --log-channels "$LOG_CHANNELS" $LOG_DIR/platform-stdout.log 2>&1 diff --git a/tools/debug-session-prep/Xamarin.Android.Utilities/XamarinLoggingHelper.cs b/tools/debug-session-prep/Xamarin.Android.Utilities/XamarinLoggingHelper.cs new file mode 100644 index 00000000000..4167c8852b6 --- /dev/null +++ b/tools/debug-session-prep/Xamarin.Android.Utilities/XamarinLoggingHelper.cs @@ -0,0 +1,174 @@ +using System; +using System.IO; + +namespace Xamarin.Android.Utilities; + +enum LogLevel +{ + Error, + Warning, + Info, + Message, + Debug +} + +class XamarinLoggingHelper +{ + static readonly object consoleLock = new object (); + + public const ConsoleColor ErrorColor = ConsoleColor.Red; + public const ConsoleColor DebugColor = ConsoleColor.DarkGray; + public const ConsoleColor InfoColor = ConsoleColor.Green; + public const ConsoleColor MessageColor = ConsoleColor.Gray; + public const ConsoleColor WarningColor = ConsoleColor.Yellow; + public const ConsoleColor StatusLabel = ConsoleColor.Cyan; + public const ConsoleColor StatusText = ConsoleColor.White; + + public bool Verbose { get; set; } + + public void Message (string? message) + { + Log (LogLevel.Message, message); + } + + public void MessageLine (string? message = null) + { + Message ($"{message ?? String.Empty}{Environment.NewLine}"); + } + + public void Warning (string? message) + { + Log (LogLevel.Warning, message); + } + + public void WarningLine (string? message = null) + { + Warning ($"{message ?? String.Empty}{Environment.NewLine}"); + } + + public void Error (string? message) + { + Log (LogLevel.Error, message); + } + + public void ErrorLine (string? message = null) + { + Error ($"{message ?? String.Empty}{Environment.NewLine}"); + } + + public void Info (string? message) + { + Log (LogLevel.Info, message); + } + + public void InfoLine (string? message = null) + { + Info ($"{message ?? String.Empty}{Environment.NewLine}"); + } + + public void Debug (string? message) + { + Log (LogLevel.Debug, message); + } + + public void DebugLine (string? message = null) + { + Debug ($"{message ?? String.Empty}{Environment.NewLine}"); + } + + public void Status (string label, string text) + { + Log (LogLevel.Info, $"{label}: ", StatusLabel); + Log (LogLevel.Info, $"{text}", StatusText); + } + + public void StatusLine (string label, string text) + { + Status (label, text); + Log (LogLevel.Info, Environment.NewLine); + } + + public void Log (LogLevel level, string? message) + { + if (!Verbose && level == LogLevel.Debug) { + return; + } + + Log (level, message, ForegroundColor (level)); + } + + public void LogLine (LogLevel level, string? message, ConsoleColor color) + { + Log (level, message, color); + Log (level, Environment.NewLine, color); + } + + public void Log (LogLevel level, string? message, ConsoleColor color) + { + if (!Verbose && level == LogLevel.Debug) { + return; + } + + TextWriter writer = level == LogLevel.Error ? Console.Error : Console.Out; + message = message ?? String.Empty; + + ConsoleColor fg = ConsoleColor.Gray; + try { + lock (consoleLock) { + fg = Console.ForegroundColor; + Console.ForegroundColor = color; + } + + writer.Write (message); + } finally { + Console.ForegroundColor = fg; + } + } + + ConsoleColor ForegroundColor (LogLevel level) => level switch { + LogLevel.Error => ErrorColor, + LogLevel.Warning => WarningColor, + LogLevel.Info => InfoColor, + LogLevel.Debug => DebugColor, + LogLevel.Message => MessageColor, + _ => MessageColor, + }; + +#region MSBuild compatibility methods + public void LogDebugMessage (string message, params object[] messageArgs) + { + if (messageArgs == null || messageArgs.Length == 0) { + DebugLine (message); + } else { + DebugLine (String.Format (message, messageArgs)); + } + } + + public void LogError (string message, params object[] messageArgs) + { + if (messageArgs == null || messageArgs.Length == 0) { + ErrorLine (message); + } else { + ErrorLine (String.Format (message, messageArgs)); + } + } + + public void LogMessage (string message, params object[] messageArgs) + { + if (messageArgs == null || messageArgs.Length == 0) { + MessageLine (message); + } else { + MessageLine (String.Format (message, messageArgs)); + } + } + + public void LogWarning (string message, params object[] messageArgs) + { + if (messageArgs == null || messageArgs.Length == 0) { + WarningLine (message); + } else { + WarningLine (String.Format (message, messageArgs)); + } + } +#endregion +} diff --git a/tools/debug-session-prep/debug-session-prep.csproj b/tools/debug-session-prep/debug-session-prep.csproj new file mode 100644 index 00000000000..5bca33d124f --- /dev/null +++ b/tools/debug-session-prep/debug-session-prep.csproj @@ -0,0 +1,35 @@ + + + + + Exe + False + $(MicrosoftAndroidSdkOutDir) + net7.0 + debug_session_prep + enable + NO_MSBUILD + + + + + + + + + + + + + xa_start_lldb_server.sh + + + lldb-debug-session.sh + + + + + + + + diff --git a/tools/xadebug/LICENSE b/tools/xadebug/LICENSE new file mode 100644 index 00000000000..cc166e065e9 --- /dev/null +++ b/tools/xadebug/LICENSE @@ -0,0 +1,24 @@ +The MIT License (MIT) + +Copyright (c) .NET Foundation Contributors + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/tools/xadebug/Main.cs b/tools/xadebug/Main.cs new file mode 100644 index 00000000000..c3f8a53b954 --- /dev/null +++ b/tools/xadebug/Main.cs @@ -0,0 +1,477 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Xml; + +using Microsoft.Build.Logging.StructuredLogger; +using Mono.Options; +using Xamarin.Android.Utilities; +using Xamarin.Tools.Zip; + +namespace Xamarin.Android.Debug; + +sealed class ParsedOptions +{ + public bool ShowHelp; + public bool Verbose; + public string Configuration = "Debug"; + public string? PackageName; + public string? Activity; + public string DotNetCommand = "dotnet"; + public string WorkDirectory = "xadebug-data"; + public string AdbPath = "adb"; + public string? TargetDevice; + public string? NdkDirPath; +} + +class XADebug +{ + const string AndroidManifestZipPath = "AndroidManifest.xml"; + const string DefaultMinSdkVersion = "21"; + + static readonly string[] NdkEnvvars = { + "ANDROID_NDK_PATH", + "ANDROID_NDK_ROOT", + }; + + static XamarinLoggingHelper log = new XamarinLoggingHelper (); + + static int Main (string[] args) + { + bool haveOptionErrors = false; + var parsedOptions = new ParsedOptions (); + log.Verbose = parsedOptions.Verbose; + + var opts = new OptionSet { + "Usage: dotnet xadebug [OPTIONS] ", + "", + { "p|package-name=", "name of the application package", v => parsedOptions.PackageName = EnsureNonEmptyString (log, "-p|--package-name", v, ref haveOptionErrors) }, + { "c|configuration=", $"{{CONFIGURATION}} in which to build the application. Ignored when running in APK-only mode. Default: {parsedOptions.Configuration}", v => parsedOptions.Configuration = v }, + { "a|activity=", "Name of the {ACTIVITY} to start the application. Default: determined from AndroidManifest.xml inside the APK", v => parsedOptions.Activity = v }, + { "d|dotnet=", $"Name of the dotnet {{COMMAND}} to use when building a project. Defaults to {parsedOptions.DotNetCommand}", v => parsedOptions.DotNetCommand = v }, + { "w|work-dir=", $"{{DIRECTORY}} in which xadebug will store build and debug logs, as well as shared libraries with symbols. Default: {parsedOptions.WorkDirectory}", v => parsedOptions.WorkDirectory = v }, + "", + { "s|adb=", "{PATH} to adb to use for this session", v => parsedOptions.AdbPath = EnsureNonEmptyString (log, "-s|--adb", v, ref haveOptionErrors) }, + { "e|device=", "ID of {DEVICE} to target for this session", v => parsedOptions.TargetDevice = EnsureNonEmptyString (log, "-e|--device", v, ref haveOptionErrors) }, + { "n|ndk-dir=", "{PATH} to to the Android NDK root directory", v => parsedOptions.NdkDirPath = v }, + "", + { "v|verbose", "Show debug messages", v => parsedOptions.Verbose = true }, + { "h|help|?", "Show this help screen", v => parsedOptions.ShowHelp = true }, + }; + + List rest = opts.Parse (args); + log.Verbose = parsedOptions.Verbose; + + DateTime now = DateTime.Now; + log.LogFilePath = Path.Combine (Path.GetFullPath (parsedOptions.WorkDirectory), $"session-{now.Year}-{now.Month:00}-{now.Day:00}-{now.Hour:00}:{now.Minute:00}:{now.Second:00}.log"); + log.StatusLine ("Session log file", log.LogFilePath); + + if (parsedOptions.ShowHelp || rest.Count == 0) { + int ret = 0; + if (!parsedOptions.ShowHelp) { + log.ErrorLine ("Path to application APK or directory with a C# project must be specified"); + log.ErrorLine (); + ret = 1; + } + + opts.WriteOptionDescriptions (Console.Out); + return ret; + } + + if (String.IsNullOrEmpty (parsedOptions.DotNetCommand)) { + log.ErrorLine ("Empty string passed in the `-d|--dotnet` parameter. It must be a non-empty string."); + haveOptionErrors = true; + } + + if (String.IsNullOrEmpty (parsedOptions.NdkDirPath)) { + string? ndk = null; + + foreach (string envvar in NdkEnvvars) { + log.DebugLine ($"Trying to read NDK path environment variable '{envvar}'"); + ndk = Environment.GetEnvironmentVariable (envvar); + if (!String.IsNullOrEmpty (ndk)) { + log.DebugLine ($"Potential NDK location: {ndk}"); + break; + } + } + + if (String.IsNullOrEmpty (ndk)) { + log.ErrorLine ("Unable to locate Android NDK from environment variables"); + log.MessageLine ("Please provide path to the NDK using the '-n|--ndk' argument"); + haveOptionErrors = true; + } else { + parsedOptions.NdkDirPath = ndk; + } + } + + if (!Directory.Exists (parsedOptions.NdkDirPath)) { + log.ErrorLine ($"NDK directory '{parsedOptions.NdkDirPath}' does not exist"); + return 1; + } + + if (haveOptionErrors) { + return 1; + } + + log.StatusLine ("Using NDK", parsedOptions.NdkDirPath); + + string aPath = rest[0]; + string? apkFilePath = null; + string? buildLogPath = null; + ZipArchive? apk = null; + + if (Directory.Exists (aPath)) { + (apkFilePath, buildLogPath) = BuildApp (aPath, parsedOptions, projectPathIsDirectory: true); + } else if (File.Exists (aPath)) { + if (String.Compare (".csproj", Path.GetExtension (aPath), StringComparison.OrdinalIgnoreCase) == 0) { + // Let's see if we can trust the file name... + (apkFilePath, buildLogPath) = BuildApp (aPath, parsedOptions, projectPathIsDirectory: false); + } else if (IsAndroidPackageFile (aPath, out apk)) { + apkFilePath = aPath; + } else { + log.ErrorLine ($"File '{aPath}' is not an Android APK package"); + log.ErrorLine (); + } + } else { + log.ErrorLine ($"Neither directory nor file '{aPath}' exist"); + log.ErrorLine (); + } + + if (!String.IsNullOrEmpty (buildLogPath)) { + log.StatusLine ("Build log", buildLogPath); + } + + if (String.IsNullOrEmpty (apkFilePath)) { + return 1; + } + + log.StatusLine ("Input APK", apkFilePath); + + if (apk == null) { + apk = OpenApk (apkFilePath); + } + + // Extract app information fromn the embedded manifest + ApplicationInfo? appInfo = ReadManifest (apk, parsedOptions); + if (appInfo == null) { + return 1; + } + + if (!appInfo.Debuggable) { + log.ErrorLine ($"Application {apkFilePath} is not debuggable."); + log.MessageLine (); + log.MessageLine ("Please rebuild the aplication either in `Debug` configuration or with appropriate properties set in `Release` configuration:"); + log.MessageLine ("TODO: fill in instructions"); + log.MessageLine (); + return 1; + } + + var debugSession = new DebugSession (log, appInfo, apkFilePath, apk, parsedOptions); + if (!debugSession.Prepare ()) { + return 1; + } + + if (!debugSession.Run ()) { + return 1; + } + + return 0; + } + + static ApplicationInfo? ReadManifest (ZipArchive apk, ParsedOptions parsedOptions) + { + ZipEntry entry = apk.ReadEntry (AndroidManifestZipPath); + + using var manifestData = new MemoryStream (); + entry.Extract (manifestData); + manifestData.Seek (0, SeekOrigin.Begin); + + // TODO: make provisions for plain XML AndroidManifest.xml, perhaps? Although not sure if it's really necesary these days anymore as the APKs should all have the + // binary version of the manifest. + var axml = new AXMLParser (manifestData, log); + XmlDocument? manifest = axml.Parse (); + if (manifest == null) { + log.ErrorLine ("Unable to parse Android manifest from the apk"); + return null; + } + + var writerSettings = new XmlWriterSettings { + Encoding = new UTF8Encoding (false), + Indent = true, + IndentChars = "\t", + NewLineOnAttributes = false, + OmitXmlDeclaration = false, + WriteEndDocumentOnClose = true, + }; + + var manifestXml = new StringBuilder (); + using var writer = XmlWriter.Create (manifestXml, writerSettings); + manifest.WriteTo (writer); + writer.Flush (); + log.DebugLine ("Android manifest from the apk: START"); + log.DebugLine (manifestXml.ToString ()); + log.DebugLine ("Android manifest from the apk: END"); + + string? packageName = null; + XmlNode? node; + + node = manifest.SelectSingleNode ("//manifest"); + if (node == null) { + log.ErrorLine ("Unable to find root element 'manifest' of AndroidManifest.xml"); + return null; + } + + var nsManager = new XmlNamespaceManager (manifest.NameTable); + if (node.Attributes != null) { + const string nsPrefix = "xmlns:"; + + foreach (XmlAttribute attr in node.Attributes) { + if (!attr.Name.StartsWith (nsPrefix, StringComparison.Ordinal)) { + continue; + } + + nsManager.AddNamespace (attr.Name.Substring (nsPrefix.Length), attr.Value); + } + } + + if (String.IsNullOrEmpty (parsedOptions.PackageName)) { + packageName = GetAttributeValue (node, "package"); + } else { + packageName = parsedOptions.PackageName; + } + + if (String.IsNullOrEmpty (packageName)) { + log.ErrorLine ("Unable to determine the package name"); + return null; + } + + node = manifest.SelectSingleNode ("//manifest/uses-sdk"); + string? minSdkVersion = GetAttributeValue (node, "android:minSdkVersion"); + if (String.IsNullOrEmpty (minSdkVersion)) { + log.WarningLine ($"Android manifest doesn't specify the minimum SDK version supported by the application, assuming the default of {DefaultMinSdkVersion}"); + minSdkVersion = DefaultMinSdkVersion; + } + + ApplicationInfo? ret; + try { + ret = new ApplicationInfo (packageName, minSdkVersion); + } catch (Exception ex) { + log.ErrorLine ($"Exception {ex.GetType ()} thrown while constructing application info: {ex.Message}"); + return null; + } + + if (String.IsNullOrEmpty (parsedOptions.Activity)) { + node = manifest.SelectSingleNode ("//manifest/application"); + string? debuggable = GetAttributeValue (node, "android:debuggable"); + if (!String.IsNullOrEmpty (debuggable)) { + ret.Debuggable = String.Compare ("true", debuggable, StringComparison.OrdinalIgnoreCase) == 0; + } + + node = manifest.SelectSingleNode ("//manifest/application/activity[./intent-filter/action[@android:name='android.intent.action.MAIN']]", nsManager); + if (node != null) { + ret.Activity = GetAttributeValue (node, "android:name"); + log.DebugLine ($"Detected main activity: {ret.Activity}"); + } + } else { + ret.Activity = parsedOptions.Activity; + } + + return ret; + } + + static string? GetAttributeValue (XmlNode? node, string prefixedAttributeName) + { + if (node?.Attributes == null) { + return null; + } + + foreach (XmlAttribute attr in node.Attributes) { + if (String.Compare (prefixedAttributeName, attr.Name, StringComparison.Ordinal) == 0) { + return attr.Value; + } + } + + return null; + } + + static string EnsureNonEmptyString (XamarinLoggingHelper log, string paramName, string? value, ref bool haveOptionErrors) + { + if (String.IsNullOrEmpty (value)) { + haveOptionErrors = true; + log.ErrorLine ($"Parameter '{paramName}' requires a non-empty string as its value"); + return String.Empty; + } + + return value; + } + + static ZipArchive OpenApk (string filePath) => ZipArchive.Open (filePath, FileMode.Open); + + static bool IsAndroidPackageFile (string filePath, out ZipArchive? apk) + { + try { + apk = OpenApk (filePath); + } catch (ZipIOException ex) { + log.DebugLine ($"Failed to open '{filePath}' as ZIP archive: {ex.Message}"); + apk = null; + return false; + } + + return apk.ContainsEntry (AndroidManifestZipPath); + } + + static (string? apkPath, string? buildLogPath) BuildApp (string projectPath, ParsedOptions parsedOptions, bool projectPathIsDirectory) + { + log.MessageLine (); + + var dotnet = new DotNetRunner (log, parsedOptions.DotNetCommand, parsedOptions.WorkDirectory); + string? logPath = dotnet.Build ( + projectPath, + parsedOptions.Configuration, + "-p:AndroidCreatePackagePerAbi=False", + "-p:AndroidPackageFormat=apk", + "-p:_AndroidAotStripLibraries=False", + "-p:_AndroidEnableNativeDebugging=True", + "-p:_AndroidStripNativeLibraries=False" + ).Result; + + if (String.IsNullOrEmpty (logPath)) { + return FinishAndReturn (null, null); + } + + string projectDir = projectPathIsDirectory ? projectPath : Path.GetDirectoryName (projectPath) ?? "."; + string? apkPath = FindApkPathFromLog (projectDir, logPath); + + if (String.IsNullOrEmpty (apkPath)) { + log.DebugLine ("Could not get APK path from build log, trying to guess"); + apkPath = TryToGuessApkPath (projectDir, parsedOptions); + } + + if (String.IsNullOrEmpty (apkPath)) { + log.ErrorLine ("Unable to determine path to the application APK file after build."); + log.MessageLine (); + log.MessageLine ("Please run `xadebug` again, passing it path to the produced APK file"); + log.MessageLine (); + return FinishAndReturn (null, logPath); + } + + if (!File.Exists (apkPath)) { + log.ErrorLine ($"APK file '{apkPath}' not found after build"); + return FinishAndReturn (null, logPath); + }; + + return FinishAndReturn (apkPath, logPath); + + (string? apkPath, string? buildLogPath) FinishAndReturn (string? apkPath, string? buildLogPath) + { + log.MessageLine (); + return (apkPath, buildLogPath); + } + } + + static string? TryToGuessApkPath (string projectDir, ParsedOptions parsedOptions) + { + log.DebugLine ("Trying to find application APK in {projectDir}"); + + string binDir = Path.Combine (projectDir, "bin", parsedOptions.Configuration); + if (!Directory.Exists (binDir)) { + log.WarningLine ($"Bin output directory '{binDir}' does not exist. Unable to determine path to the produced APK"); + return null; + } + + const string ApkSuffix = "-Signed.apk"; + string apkName; + bool apkNameIsGlob; + + if (!String.IsNullOrEmpty (parsedOptions.PackageName)) { + apkName = $"{parsedOptions.PackageName}{ApkSuffix}"; + apkNameIsGlob = false; + } else { + apkName = $"*{ApkSuffix}"; + apkNameIsGlob = true; + } + + log.StatusLine ("Looking for APK with name", apkName); + + if (!apkNameIsGlob) { + string apkPath = Path.Combine (binDir, apkName); + LogPotentialPath (apkPath); + + if (File.Exists (apkPath)) { + return LogFoundApkPathAndReturn (apkPath); + } + } + + // Find subdirectories named netX.Y-android and the apk files inside them + var apkFiles = new List (); + foreach (string dir in Directory.EnumerateDirectories (binDir, "net*-android")) { + if (apkNameIsGlob) { + foreach (string file in Directory.EnumerateFiles (dir, apkName)) { + // We know it exists, but the method also logs paths + AddApkIfExists (file, apkFiles); + } + } else { + AddApkIfExists (Path.Combine (dir, apkName), apkFiles); + } + } + + if (apkFiles.Count == 0) { + return null; + } + + string selectedApkPath; + if (apkFiles.Count > 1) { + // TODO: ask the user to select one + throw new NotImplementedException ("Support for multiple APK files not implemented yet"); + } else { + selectedApkPath = apkFiles[0]; + } + + return LogFoundApkPathAndReturn (selectedApkPath); + + void AddApkIfExists (string apkPath, List apkFiles) + { + LogPotentialPath (apkPath); + if (File.Exists (apkPath)) { + log.DebugLine ($"Found APK: {apkPath}"); + apkFiles.Add (apkPath); + } + } + + void LogPotentialPath (string path) + { + log.DebugLine ($"Trying path: {path}"); + } + } + + static string? FindApkPathFromLog (string projectDir, string? logPath) + { + if (String.IsNullOrEmpty (logPath)) { + return null; + } + + log.DebugLine ($"Trying to find APK file path in the build log ('{logPath}')"); + + Build build = BinaryLog.ReadBuild (logPath); + foreach (Property prop in build.FindChildrenRecursive ()) { + if (String.Compare ("ApkFileSigned", prop.Name, StringComparison.Ordinal) != 0) { + continue; + } + + if (Path.IsPathRooted (prop.Value)) { + return LogFoundApkPathAndReturn (prop.Value); + } + + return LogFoundApkPathAndReturn (Path.Combine (projectDir, prop.Value)); + } + + return null; + } + + static string LogFoundApkPathAndReturn (string path) + { + log.DebugLine ($"Returning APK path: {path}"); + return path; + } +} diff --git a/tools/xadebug/README.md b/tools/xadebug/README.md new file mode 100644 index 00000000000..30404ce4c54 --- /dev/null +++ b/tools/xadebug/README.md @@ -0,0 +1 @@ +TODO \ No newline at end of file diff --git a/tools/xadebug/Resources/xa_start_lldb_server.sh b/tools/xadebug/Resources/xa_start_lldb_server.sh new file mode 100755 index 00000000000..d024cced9c7 --- /dev/null +++ b/tools/xadebug/Resources/xa_start_lldb_server.sh @@ -0,0 +1,41 @@ +#!/system/bin/sh + +# This script launches lldb-server on Android device from application subfolder - /data/data/$packageId/lldb/bin. +# Native run configuration is expected to push this script along with lldb-server to the device prior to its execution. +# Following command arguments are expected to be passed - lldb package directory and lldb-server listen port. +set -x +umask 0002 + +LLDB_DIR="$1" +LISTENER_SCHEME="$2" +DOMAINSOCKET_DIR="$3" +PLATFORM_SOCKET="$4" +LOG_CHANNELS="$5" + +BIN_DIR="$LLDB_DIR/bin" +LOG_DIR="$LLDB_DIR/log" +TMP_DIR="$LLDB_DIR/tmp" +PLATFORM_LOG_FILE="$LOG_DIR/platform.log" + +export LLDB_DEBUGSERVER_LOG_FILE="$LOG_DIR/gdb-server.log" +export LLDB_SERVER_LOG_CHANNELS="$LOG_CHANNELS" +export LLDB_DEBUGSERVER_DOMAINSOCKET_DIR="$DOMAINSOCKET_DIR" + +# This directory already exists. Make sure it has the right permissions. +chmod 0775 "$LLDB_DIR" + +rm -r "$TMP_DIR" +mkdir "$TMP_DIR" +export TMPDIR="$TMP_DIR" + +rm -r "$LOG_DIR" +mkdir "$LOG_DIR" + +# LLDB would create these files with more restrictive permissions than our umask above. Make sure +# it doesn't get a chance. +# "touch" does not exist on pre API-16 devices. This is a poor man's replacement +cat "$LLDB_DEBUGSERVER_LOG_FILE" 2> "$PLATFORM_LOG_FILE" + +cd $TMP_DIR # change cwd + +"$BIN_DIR/lldb-server" platform --server --listen "$LISTENER_SCHEME://$DOMAINSOCKET_DIR/$PLATFORM_SOCKET" --log-file "$PLATFORM_LOG_FILE" --log-channels "$LOG_CHANNELS" "$LOG_DIR/platform-stdout.log" 2>&1 diff --git a/tools/xadebug/Xamarin.Android.Debug/AXMLParser.cs b/tools/xadebug/Xamarin.Android.Debug/AXMLParser.cs new file mode 100644 index 00000000000..90b6f088e0f --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Debug/AXMLParser.cs @@ -0,0 +1,688 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Xml; + +using Xamarin.Android.Utilities; + +namespace Xamarin.Android.Debug; + +enum ChunkType : ushort +{ + Null = 0x0000, + StringPool = 0x0001, + Table = 0x0002, + Xml = 0x0003, + + XmlFirstChunk = 0x0100, + XmlStartNamespace = 0x0100, + XmlEndNamespace = 0x0101, + XmlStartElement = 0x0102, + XmlEndElement = 0x0103, + XmlCData = 0x0104, + XmlLastChunk = 0x017f, + XmlResourceMap = 0x0180, + + TablePackage = 0x0200, + TableType = 0x0201, + TableTypeSpec = 0x0202, + TableLibrary = 0x0203, +} + +enum AttributeType : uint +{ + // The 'data' field is either 0 or 1, specifying this resource is either undefined or empty, respectively. + Null = 0x00, + + // The 'data' field holds a ResTable_ref, a reference to another resource + Reference = 0x01, + + // The 'data' field holds an attribute resource identifier. + Attribute = 0x02, + + // The 'data' field holds an index into the containing resource table's global value string pool. + String = 0x03, + + // The 'data' field holds a single-precision floating point number. + Float = 0x04, + + // The 'data' holds a complex number encoding a dimension value such as "100in". + Dimension = 0x05, + + // The 'data' holds a complex number encoding a fraction of a container. + Fraction = 0x06, + + // The 'data' holds a dynamic ResTable_ref, which needs to be resolved before it can be used like a Reference + DynamicReference = 0x07, + + // The 'data' holds an attribute resource identifier, which needs to be resolved before it can be used like a Attribute. + DynamicAttribute = 0x08, + + // The 'data' is a raw integer value of the form n..n. + IntDec = 0x10, + + // The 'data' is a raw integer value of the form 0xn..n. + IntHex = 0x11, + + // The 'data' is either 0 or 1, for input "false" or "true" respectively. + IntBoolean = 0x12, + + // The 'data' is a raw integer value of the form #aarrggbb. + IntColorARGB8 = 0x1c, + + // The 'data' is a raw integer value of the form #rrggbb. + IntColorRGB8 = 0x1d, + + // The 'data' is a raw integer value of the form #argb. + IntColorARGB4 = 0x1e, + + // The 'data' is a raw integer value of the form #rgb. + IntColorRGB4 = 0x1f, +} + +// +// Based on https://github.com/androguard/androguard/tree/832104db3eb5dc3cc66b30883fa8ce8712dfa200/androguard/core/axml +// +class AXMLParser +{ + // Position of fields inside an attribute + const int ATTRIBUTE_IX_NAMESPACE_URI = 0; + const int ATTRIBUTE_IX_NAME = 1; + const int ATTRIBUTE_IX_VALUE_STRING = 2; + const int ATTRIBUTE_IX_VALUE_TYPE = 3; + const int ATTRIBUTE_IX_VALUE_DATA = 4; + const int ATTRIBUTE_LENGHT = 5; + + const long MinimumDataSize = 8; + const long MaximumDataSize = (long)UInt32.MaxValue; + + const uint ComplexUnitMask = 0x0f; + + static readonly float[] RadixMultipliers = { + 0.00390625f, + 3.051758E-005f, + 1.192093E-007f, + 4.656613E-010f, + }; + + static readonly string[] DimensionUnits = { + "px", + "dip", + "sp", + "pt", + "in", + "mm", + }; + + static readonly string[] FractionUnits = { + "%", + "%p", + }; + + readonly XamarinLoggingHelper log; + + Stream data; + long dataSize; + ARSCHeader axmlHeader; + uint fileSize; + StringBlock stringPool; + bool valid = true; + long initialPosition; + + public bool IsValid => valid; + + public AXMLParser (Stream data, XamarinLoggingHelper logger) + { + log = logger; + + this.data = data; + dataSize = data.Length; + + // Minimum is a single ARSCHeader, which would be a strange edge case... + if (dataSize < MinimumDataSize) { + throw new InvalidDataException ($"Input data size too small for it to be valid AXML content ({dataSize} < {MinimumDataSize})"); + } + + // This would be even stranger, if an AXML file is larger than 4GB... + // But this is not possible as the maximum chunk size is a unsigned 4 byte int. + if (dataSize > MaximumDataSize) { + throw new InvalidDataException ($"Input data size too large for it to be a valid AXML content ({dataSize} > {MaximumDataSize})"); + } + + try { + axmlHeader = new ARSCHeader (data); + } catch (Exception) { + log.ErrorLine ("Error parsing the first data header"); + throw; + } + + if (axmlHeader.HeaderSize != 8) { + throw new InvalidDataException ($"This does not look like AXML data. header size does not equal 8. header size = {axmlHeader.Size}"); + } + + fileSize = axmlHeader.Size; + if (fileSize > dataSize) { + throw new InvalidDataException ($"This does not look like AXML data. Declared data size does not match real size: {fileSize} vs {dataSize}"); + } + + if (fileSize < dataSize) { + log.WarningLine ($"Declared data size ({fileSize}) is smaller than total data size ({dataSize}). Was something appended to the file? Trying to parse it anyways."); + } + + if (axmlHeader.Type != ChunkType.Xml) { + log.WarningLine ($"AXML file has an unusual resource type, trying to parse it anyways. Resource Type: 0x{(ushort)axmlHeader.Type:04x}"); + } + + ARSCHeader stringPoolHeader = new ARSCHeader (data, ChunkType.StringPool); + if (stringPoolHeader.HeaderSize != 28) { + throw new InvalidDataException ($"This does not look like an AXML file. String chunk header size does not equal 28. Header size = {stringPoolHeader.Size}"); + } + + stringPool = new StringBlock (logger, data, stringPoolHeader); + initialPosition = data.Position; + } + + public XmlDocument? Parse () + { + // Reset position in case we're called more than once, for whatever reason + data.Seek (initialPosition, SeekOrigin.Begin); + valid = true; + + XmlDocument ret = new XmlDocument (); + XmlDeclaration declaration = ret.CreateXmlDeclaration ("1.0", stringPool.IsUTF8 ? "UTF-8" : "UTF-16", null); + ret.InsertBefore (declaration, ret.DocumentElement); + + using var reader = new BinaryReader (data, Encoding.UTF8, leaveOpen: true); + ARSCHeader? header; + string? nsPrefix = null; + string? nsUri = null; + uint prefixIndex = 0; + uint uriIndex = 0; + var nsUriToPrefix = new Dictionary (StringComparer.Ordinal); + XmlNode? currentNode = ret.DocumentElement; + + while (data.Position < dataSize) { + header = new ARSCHeader (data); + + // Special chunk: Resource Map. This chunk might follow the string pool. + if (header.Type == ChunkType.XmlResourceMap) { + if (!SkipOverResourceMap (header, reader)) { + valid = false; + break; + } + continue; + } + + // XML chunks + + // Skip over unknown types + if (!Enum.IsDefined (typeof(ChunkType), header.TypeRaw)) { + log.WarningLine ($"Unknown chunk type 0x{header.TypeRaw:x} at offset {data.Position}. Skipping over {header.Size} bytes"); + data.Seek (header.Size, SeekOrigin.Current); + continue; + } + + // Check that we read a correct header + if (header.HeaderSize != 16) { + log.WarningLine ($"XML chunk header size is not 16. Chunk type {header.Type} (0x{header.TypeRaw:x}), chunk size {header.Size}"); + data.Seek (header.Size, SeekOrigin.Current); + continue; + } + + // Line Number of the source file, only used as meta information + uint lineNumber = reader.ReadUInt32 (); + + // Comment_Index (usually 0xffffffff) + uint commentIndex = reader.ReadUInt32 (); + + if (commentIndex != 0xffffffff && (header.Type == ChunkType.XmlStartNamespace || header.Type == ChunkType.XmlEndNamespace)) { + log.WarningLine ($"Unhandled Comment at namespace chunk: {commentIndex}"); + } + + if (header.Type == ChunkType.XmlStartNamespace) { + prefixIndex = reader.ReadUInt32 (); + uriIndex = reader.ReadUInt32 (); + + nsPrefix = stringPool.GetString (prefixIndex); + nsUri = stringPool.GetString (uriIndex); + + if (!String.IsNullOrEmpty (nsUri)) { + nsUriToPrefix[nsUri] = nsPrefix ?? String.Empty; + } + + log.DebugLine ($"Start of Namespace mapping: prefix {prefixIndex}: '{nsPrefix}' --> uri {uriIndex}: '{nsUri}'"); + + if (String.IsNullOrEmpty (nsUri)) { + log.WarningLine ($"Namespace prefix '{nsPrefix}' resolves to empty URI."); + } + + continue; + } + + if (header.Type == ChunkType.XmlEndNamespace) { + // Namespace handling is **really** simplified, since we expect to deal only with AndroidManifest.xml which should have just one namespace. + // There should be no problems with that. Famous last words. + uint endPrefixIndex = reader.ReadUInt32 (); + uint endUriIndex = reader.ReadUInt32 (); + + log.DebugLine ($"End of Namespace mapping: prefix {endPrefixIndex}, uri {endUriIndex}"); + if (endPrefixIndex != prefixIndex) { + log.WarningLine ($"Prefix index of Namespace end doesn't match the last Namespace prefix index: {prefixIndex} != {endPrefixIndex}"); + } + + if (endUriIndex != uriIndex) { + log.WarningLine ($"URI index of Namespace end doesn't match the last Namespace URI index: {uriIndex} != {endUriIndex}"); + } + + string? endUri = stringPool.GetString (endUriIndex); + if (!String.IsNullOrEmpty (endUri) && nsUriToPrefix.ContainsKey (endUri)) { + nsUriToPrefix.Remove (endUri); + } + + nsPrefix = null; + nsUri = null; + prefixIndex = 0; + uriIndex = 0; + + continue; + } + + uint tagNsUriIndex; + uint tagNameIndex; + string? tagName; +// string? tagNs; // TODO: implement + + if (header.Type == ChunkType.XmlStartElement) { + // The TAG consists of some fields: + // * (chunk_size, line_number, comment_index - we read before) + // * namespace_uri + // * name + // * flags + // * attribute_count + // * class_attribute + // After that, there are two lists of attributes, 20 bytes each + tagNsUriIndex = reader.ReadUInt32 (); + tagNameIndex = reader.ReadUInt32 (); + uint tagFlags = reader.ReadUInt32 (); + uint attributeCount = reader.ReadUInt32 () & 0xffff; + uint classAttribute = reader.ReadUInt32 (); + + // Tag name is, of course, required but instead of throwing an exception should we find none, we use a fake name in hope that we can still salvage + // the document. + tagName = stringPool.GetString (tagNameIndex) ?? "unnamedTag"; + log.DebugLine ($"Start of tag '{tagName}', NS URI index {tagNsUriIndex}"); + log.DebugLine ($"Reading tag attributes ({attributeCount}):"); + + string? tagNsUri = tagNsUriIndex != 0xffffffff ? stringPool.GetString (tagNsUriIndex) : null; + string? tagNsPrefix; + + if (String.IsNullOrEmpty (tagNsUri) || !nsUriToPrefix.TryGetValue (tagNsUri, out tagNsPrefix)) { + tagNsPrefix = null; + } + + XmlElement element = ret.CreateElement (tagNsPrefix, tagName, tagNsUri); + if (currentNode == null) { + ret.AppendChild (element); + if (!String.IsNullOrEmpty (nsPrefix) && !String.IsNullOrEmpty (nsUri)) { + ret.DocumentElement!.SetAttribute ($"xmlns:{nsPrefix}", nsUri); + } + } else { + currentNode.AppendChild (element); + } + currentNode = element; + + for (uint i = 0; i < attributeCount; i++) { + uint attrNsIdx = reader.ReadUInt32 (); // string index + uint attrNameIdx = reader.ReadUInt32 (); // string index + uint attrValue = reader.ReadUInt32 (); + uint attrType = reader.ReadUInt32 () >> 24; + uint attrData = reader.ReadUInt32 (); + + string? attrNs = attrNsIdx != 0xffffffff ? stringPool.GetString (attrNsIdx) : String.Empty; + string? attrName = stringPool.GetString (attrNameIdx); + + if (String.IsNullOrEmpty (attrName)) { + log.WarningLine ($"Attribute without name, ignoring. Offset: {data.Position}"); + continue; + } + + log.DebugLine ($" '{attrName}': ns == '{attrNs}'; value == 0x{attrValue:x}; type == 0x{attrType:x}; data == 0x{attrData:x}"); + XmlAttribute attr; + + if (!String.IsNullOrEmpty (attrNs)) { + attr = ret.CreateAttribute (nsUriToPrefix[attrNs], attrName, attrNs); + } else { + attr = ret.CreateAttribute (attrName!); + } + attr.Value = GetAttributeValue (attrValue, attrType, attrData); + element.SetAttributeNode (attr); + } + continue; + } + + if (header.Type == ChunkType.XmlEndElement) { + tagNsUriIndex = reader.ReadUInt32 (); + tagNameIndex = reader.ReadUInt32 (); + + tagName = stringPool.GetString (tagNameIndex); + log.DebugLine ($"End of tag '{tagName}', NS URI index {tagNsUriIndex}"); + currentNode = currentNode?.ParentNode!; + continue; + } + + // TODO: add support for CDATA + } + + return ret; + } + + string GetAttributeValue (uint attrValue, uint attrType, uint attrData) + { + if (!Enum.IsDefined (typeof(AttributeType), attrType)) { + log.WarningLine ($"Unknown attribute type value 0x{attrType:x}, returning empty attribute value (data == 0x{attrData:x}). Offset: {data.Position}"); + return String.Empty; + } + + switch ((AttributeType)attrType) { + case AttributeType.Null: + return attrData == 0 ? "?NULL?" : String.Empty; + + case AttributeType.Reference: + return $"@{MaybePrefix()}{attrData:x08}"; + + case AttributeType.Attribute: + return $"?{MaybePrefix()}{attrData:x08}"; + + case AttributeType.String: + return stringPool.GetString (attrData) ?? String.Empty; + + case AttributeType.Float: + return $"{(float)attrData}"; + + case AttributeType.Dimension: + return $"{ComplexToFloat(attrData)}{DimensionUnits[attrData & ComplexUnitMask]}"; + + case AttributeType.Fraction: + return $"{ComplexToFloat(attrData) * 100.0f}{FractionUnits[attrData & ComplexUnitMask]}"; + + case AttributeType.IntDec: + return attrData.ToString (); + + case AttributeType.IntHex: + return $"0x{attrData:X08}"; + + case AttributeType.IntBoolean: + return attrData == 0 ? "false" : "true"; + + case AttributeType.IntColorARGB8: + case AttributeType.IntColorRGB8: + case AttributeType.IntColorARGB4: + case AttributeType.IntColorRGB4: + return $"#{attrData:X08}"; + } + + return String.Empty; + + string MaybePrefix () + { + if (attrData >> 24 == 1) { + return "android:"; + } + return String.Empty; + } + + float ComplexToFloat (uint value) + { + return (float)(value & 0xffffff00) * RadixMultipliers[(value >> 4) & 3]; + } + } + + bool SkipOverResourceMap (ARSCHeader header, BinaryReader reader) + { + log.DebugLine ("AXML contains a resource map"); + + // Check size: < 8 bytes mean that the chunk is not complete + // Should be aligned to 4 bytes. + if (header.Size < 8 || (header.Size % 4) != 0) { + log.ErrorLine ("Invalid chunk size in chunk XML_RESOURCE_MAP"); + return false; + } + + // Since our main interest is in reading AndroidManifest.xml, we're going to skip over the table + for (int i = 0; i < (header.Size - header.HeaderSize) / 4; i++) { + reader.ReadUInt32 (); + } + + return true; + } +} + +class StringBlock +{ + const uint FlagSorted = 1 << 0; + const uint FlagUTF8 = 1 << 0; + + XamarinLoggingHelper log; + ARSCHeader header; + uint stringCount; + uint stringsOffset; + uint flags; + bool isUTF8; + List stringOffsets; + byte[] chars; + Dictionary stringCache; + + public uint StringCount => stringCount; + public bool IsUTF8 => isUTF8; + + public StringBlock (XamarinLoggingHelper logger, Stream data, ARSCHeader stringPoolHeader) + { + log = logger; + header = stringPoolHeader; + + using var reader = new BinaryReader (data, Encoding.UTF8, leaveOpen: true); + + stringCount = reader.ReadUInt32 (); + uint styleCount = reader.ReadUInt32 (); + + flags = reader.ReadUInt32 (); + isUTF8 = (flags & FlagUTF8) == FlagUTF8; + + stringsOffset = reader.ReadUInt32 (); + uint stylesOffset = reader.ReadUInt32 (); + + if (styleCount == 0 && stylesOffset > 0) { + log.InfoLine ("Styles Offset given, but styleCount is zero. This is not a problem but could indicate packers."); + } + + stringOffsets = new List (); + + for (uint i = 0; i < stringCount; i++) { + stringOffsets.Add (reader.ReadUInt32 ()); + } + + // We're not interested in styles, skip over their offsets + for (uint i = 0; i < styleCount; i++) { + reader.ReadUInt32 (); + } + + bool haveStyles = stylesOffset != 0 && styleCount != 0; + uint size = header.Size - stringsOffset; + if (haveStyles) { + size = stylesOffset - stringsOffset; + } + + if (size % 4 != 0) { + log.WarningLine ("Size of strings is not aligned on four bytes."); + } + + chars = new byte[size]; + reader.Read (chars, 0, (int)size); + + if (haveStyles) { + size = header.Size - stylesOffset; + + if (size % 4 != 0) { + log.WarningLine ("Size of styles is not aligned on four bytes."); + } + + // Not interested in them, skip + for (uint i = 0; i < size / 4; i++) { + reader.ReadUInt32 (); + } + } + + stringCache = new Dictionary (); + } + + public string? GetString (uint idx) + { + if (stringCache.TryGetValue (idx, out string? ret)) { + return ret; + } + + if (idx < 0 || idx > stringOffsets.Count || stringOffsets.Count == 0) { + return null; + } + + uint offset = stringOffsets[(int)idx]; + if (isUTF8) { + ret = DecodeUTF8 (offset); + } else { + ret = DecodeUTF16 (offset); + } + stringCache[idx] = ret; + + return ret; + } + + string DecodeUTF8 (uint offset) + { + // UTF-8 Strings contain two lengths, as they might differ: + // 1) the string length in characters + (uint length, uint nbytes) = DecodeLength (offset, sizeOfChar: 1); + offset += nbytes; + + // 2) the number of bytes the encoded string occupies + (uint encodedBytes, nbytes) = DecodeLength (offset, sizeOfChar: 1); + offset += nbytes; + + if (chars[offset + encodedBytes] != 0) { + throw new InvalidDataException ($"UTF-8 string is not NUL-terminated. Offset: offset"); + } + + return Encoding.UTF8.GetString (chars, (int)offset, (int)encodedBytes); + } + + string DecodeUTF16 (uint offset) + { + (uint length, uint nbytes) = DecodeLength (offset, sizeOfChar: 2); + offset += nbytes; + + uint encodedBytes = length * 2; + if (chars[offset + encodedBytes] != 0 && chars[offset + encodedBytes + 1] != 0) { + throw new InvalidDataException ($"UTF-16 string is not NUL-terminated. Offset: offset"); + } + + return Encoding.Unicode.GetString (chars, (int)offset, (int)encodedBytes); + } + + (uint length, uint nbytes) DecodeLength (uint offset, uint sizeOfChar) + { + uint sizeOfTwoChars = sizeOfChar << 1; + uint highBit = 0x80u << (8 * ((int)sizeOfChar - 1)); + uint length1, length2; + + // Length is tored as 1 or 2 characters of `sizeofChar` size + if (sizeOfChar == 1) { + // UTF-8 encoding, each character is a byte + length1 = chars[offset]; + length2 = chars[offset + 1]; + } else { + // UTF-16 encoding, each character is a short + length1 = (uint)((chars[offset]) | (chars[offset + 1] << 8)); + length2 = (uint)((chars[offset + 2]) | (chars[offset + 3] << 8)); + } + + uint length; + uint nbytes; + if ((length1 & highBit) != 0) { + length = ((length1 & ~highBit) << (8 * (int)sizeOfChar)) | length2; + nbytes = sizeOfTwoChars; + } else { + length = length1; + nbytes = sizeOfChar; + } + + // 8 bit strings: maximum of 0x7FFF bytes, http://androidxref.com/9.0.0_r3/xref/frameworks/base/libs/androidfw/ResourceTypes.cpp#692 + // 16 bit strings: maximum of 0x7FFFFFF bytes, http://androidxref.com/9.0.0_r3/xref/frameworks/base/libs/androidfw/ResourceTypes.cpp#670 + if (sizeOfChar == 1) { + if (length > 0x7fff) { + throw new InvalidDataException ("UTF-8 string is too long. Offset: {offset}"); + } + } else { + if (length > 0x7fffffff) { + throw new InvalidDataException ("UTF-16 string is too long. Offset: {offset}"); + } + } + + return (length, nbytes); + } +} + +class ARSCHeader +{ + // This is the minimal size such a header must have. There might be other header data too! + const long MinimumSize = 2 + 2 + 4; + + long start; + uint size; + ushort type; + ushort headerSize; + bool unknownType; + + public ChunkType Type => unknownType ? ChunkType.Null : (ChunkType)type; + public ushort TypeRaw => type; + public ushort HeaderSize => headerSize; + public uint Size => size; + public long End => start + (long)size; + + public ARSCHeader (Stream data, ChunkType? expectedType = null) + { + start = data.Position; + if (data.Length < start + MinimumSize) { + throw new InvalidDataException ($"Input data not large enough. Offset: {start}"); + } + + // Data in AXML is little-endian, which is fortuitous as that's the only format BinaryReader understands. + using BinaryReader reader = new BinaryReader (data, Encoding.UTF8, leaveOpen: true); + + // ushort: type + // ushort: header_size + // uint: size + type = reader.ReadUInt16 (); + headerSize = reader.ReadUInt16 (); + + // Total size of the chunk, including the header + size = reader.ReadUInt32 (); + + if (expectedType != null && type != (ushort)expectedType) { + throw new InvalidOperationException ($"Header type is not equal to the expected type ({expectedType}): got 0x{type:x}, expected 0x{(ushort)expectedType:x}"); + } + + unknownType = !Enum.IsDefined (typeof(ChunkType), type); + + if (headerSize < MinimumSize) { + throw new InvalidDataException ($"Declared header size is smaller than required size of {MinimumSize}. Offset: {start}"); + } + + if (size < MinimumSize) { + throw new InvalidDataException ($"Declared chunk size is smaller than required size of {MinimumSize}. Offset: {start}"); + } + + if (size < headerSize) { + throw new InvalidDataException ($"Declared chunk size ({size}) is smaller than header size ({headerSize})! Offset: {start}"); + } + } +} diff --git a/tools/xadebug/Xamarin.Android.Debug/AndroidDevice.cs b/tools/xadebug/Xamarin.Android.Debug/AndroidDevice.cs new file mode 100644 index 00000000000..ad444409cc6 --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Debug/AndroidDevice.cs @@ -0,0 +1,426 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Xamarin.Android.Tasks; +using Xamarin.Android.Utilities; + +namespace Xamarin.Android.Debug; + +class AndroidDevice +{ + const string ServerLauncherScriptName = "xa_start_lldb_server.sh"; + + static readonly string[] abiProperties = { + // new properties + "ro.product.cpu.abilist", + + // old properties + "ro.product.cpu.abi", + "ro.product.cpu.abi2", + }; + + static readonly string[] serialNumberProperties = { + "ro.serialno", + "ro.boot.serialno", + }; + + string packageName; + string adbPath; + List supportedAbis; + int apiLevel = -1; + string? appDataDir; + string? appLldbBaseDir; + string? appLldbBinDir; + string? appLldbLogDir; + string? appLldbTmpDir; + string? mainAbi; + string? mainArch; + string[]? deviceABIs; + string[]? availableAbis; + string[]? availableArches; + string? serialNumber; + bool appIs64Bit; + string? deviceLdd; + string? deviceDebugServerPath; + string? deviceDebugServerScriptPath; + string outputDir; + + XamarinLoggingHelper log; + AdbRunner2 adb; + AndroidNdk ndk; + + public int ApiLevel => apiLevel; + public string[] AvailableAbis => availableAbis ?? new string[] {}; + public string[] AvailableArches => availableArches ?? new string[] {}; + public string[] DeviceAbis => deviceABIs ?? new string[] {}; + public string MainArch => mainArch ?? String.Empty; + public string MainAbi => mainAbi ?? String.Empty; + public string SerialNumber => serialNumber ?? String.Empty; + public string DebugServerLauncherScriptPath => deviceDebugServerScriptPath ?? String.Empty; + public string DebugServerPath => deviceDebugServerPath ?? String.Empty; + public string LldbBaseDir => appLldbBaseDir ?? String.Empty; + public string AppDataDir => appDataDir ?? String.Empty; + public string? DeviceLddPath => deviceLdd; + public AdbRunner2 AdbRunner => adb; + + public AndroidDevice (XamarinLoggingHelper log, IProcessOutputLogger processLogger, AndroidNdk ndk, string outputDir, string adbPath, string packageName, List supportedAbis, string? adbTargetDevice = null) + { + this.adbPath = adbPath; + this.log = log; + this.packageName = packageName; + this.supportedAbis = supportedAbis; + this.ndk = ndk; + this.outputDir = outputDir; + + adb = new AdbRunner2 (log, processLogger, adbPath, adbTargetDevice); + } + + // TODO: implement manual error checking on API 21, since `adb` won't ever return any error code other than 0 - we need to look at the output of any command to determine + // whether or not it was successful. Ugh. + public bool GatherInfo () + { + (bool success, string output) = adb.GetPropertyValue ("ro.build.version.sdk").Result; + if (!success || String.IsNullOrEmpty (output) || !Int32.TryParse (output, out apiLevel)) { + log.ErrorLine ("Unable to determine connected device's API level"); + return false; + } + + // Warn on old Pixel C firmware (b/29381985). Newer devices may have Yama + // enabled but still work with ndk-gdb (b/19277529). + (success, output) = adb.Shell ("cat", "/proc/sys/kernel/yama/ptrace_scope", "2>/dev/null").Result; + if (success && + YamaOK (output.Trim ()) && + PropertyIsEqualTo (adb.GetPropertyValue ("ro.build.product").Result, "dragon") && + PropertyIsEqualTo (adb.GetPropertyValue ("ro.product.name").Result, "ryu") + ) { + log.WarningLine ("WARNING: The device uses Yama ptrace_scope to restrict debugging. ndk-gdb will"); + log.WarningLine (" likely be unable to attach to a process. With root access, the restriction"); + log.WarningLine (" can be lifted by writing 0 to /proc/sys/kernel/yama/ptrace_scope. Consider"); + log.WarningLine (" upgrading your Pixel C to MXC89L or newer, where Yama is disabled."); + log.WarningLine (); + } + + if (!DetermineArchitectureAndABI ()) { + return false; + } + + if (!DetermineAppDataDirectory ()) { + return false; + } + + serialNumber = GetFirstFoundPropertyValue (serialNumberProperties); + if (String.IsNullOrEmpty (serialNumber)) { + log.WarningLine ("Unable to determine device serial number"); + } + + return true; + + bool YamaOK (string output) + { + return !String.IsNullOrEmpty (output) && String.Compare ("0", output, StringComparison.Ordinal) != 0; + } + + bool PropertyIsEqualTo ((bool haveProperty, string value) result, string expected) + { + return + result.haveProperty && + !String.IsNullOrEmpty (result.value) && + String.Compare (result.value, expected, StringComparison.Ordinal) == 0; + } + } + + public bool Prepare (out string? mainProcessPath) + { + mainProcessPath = null; + if (!DetectTools ()) { + return false; + } + + if (!PushDebugServer ()) { + return false; + } + + if (!PullLibraries (out mainProcessPath)) { + return false; + } + + return true; + } + + bool PullLibraries (out string? mainProcessPath) + { + mainProcessPath = null; + DeviceLibraryCopier copier; + + if (String.IsNullOrEmpty (deviceLdd)) { + copier = new NoLddDeviceLibraryCopier ( + log, + adb, + appIs64Bit, + outputDir, + this + ); + } else { + copier = new LddDeviceLibraryCopier ( + log, + adb, + appIs64Bit, + outputDir, + this + ); + } + + return copier.Copy (out mainProcessPath); + } + + bool PushDebugServer () + { + string? debugServerPath = ndk.GetDebugServerPath (mainAbi!); + if (String.IsNullOrEmpty (debugServerPath)) { + return false; + } + + if (!CreateLldbDir (appLldbBinDir!) || !CreateLldbDir (appLldbLogDir) || !CreateLldbDir (appLldbTmpDir)) { + return false; + } + + //string serverName = $"xa-{context.arch}-{Path.GetFileName (debugServerPath)}"; + string serverName = Path.GetFileName (debugServerPath); + deviceDebugServerPath = $"{appLldbBinDir}/{serverName}"; + + KillDebugServer (deviceDebugServerPath); + + // Always push the server binary, as we don't know what version might already be there + if (!PushServerExecutable (debugServerPath, deviceDebugServerPath)) { + return false; + } + + string? launcherScript = Utilities.ReadManifestResource (log, ServerLauncherScriptName); + if (String.IsNullOrEmpty (launcherScript)) { + return false; + } + + string launcherScriptPath = Path.Combine (outputDir, ServerLauncherScriptName); + Directory.CreateDirectory (Path.GetDirectoryName (launcherScriptPath)!); + File.WriteAllText (launcherScriptPath, launcherScript, Utilities.UTF8NoBOM); + + deviceDebugServerScriptPath = $"{appLldbBinDir}/{Path.GetFileName (launcherScriptPath)}"; + if (!PushServerExecutable (launcherScriptPath, deviceDebugServerScriptPath)) { + return false; + } + + return true; + + bool CreateLldbDir (string? dir) + { + if (String.IsNullOrEmpty (dir)) { + return false; + } + + if (!adb.CreateDirectoryAs (packageName, dir).Result.success) { + log.ErrorLine ($"Failed to create debug server destination directory on device, {dir}"); + return false; + } + + return true; + } + } + + bool PushServerExecutable (string hostSource, string deviceDestination) + { + string executableName = Path.GetFileName (deviceDestination); + + // Always push the executable, as we don't know what version might already be there + log.DebugLine ($"Uploading {hostSource} to device"); + + // First upload to temporary path, as it's writable for everyone + string remotePath = $"/data/local/tmp/{executableName}"; + if (!adb.Push (hostSource, remotePath).Result) { + log.ErrorLine ($"Failed to upload debug server {hostSource} to device path {remotePath}"); + return false; + } + + // Next, copy it to the app dir, with run-as + (bool success, string output) = adb.Shell ( + "cat", remotePath, "|", + "run-as", packageName, + "sh", "-c", $"'cat > {deviceDestination}'" + ).Result; + + if (!success) { + log.ErrorLine ($"Failed to copy debug executable to device, from {hostSource} to {deviceDestination}"); + return false; + } + + (success, output) = adb.RunAs (packageName, "chmod", "700", deviceDestination).Result; + if (!success) { + log.ErrorLine ($"Failed to make debug server executable on device, at {deviceDestination}"); + return false; + } + + return true; + } + + // TODO: handle multiple pids + bool KillDebugServer (string debugServerPath) + { + long serverPID = GetDeviceProcessID (debugServerPath, quiet: true); + if (serverPID <= 0) { + return true; + } + + log.DebugLine ("Killing previous instance of the debug server"); + (bool success, string _) = adb.RunAs (packageName, "kill", "-9", $"{serverPID}").Result; + return success; + } + + long GetDeviceProcessID (string processName, bool quiet = false) + { + (bool success, string output) = adb.Shell ("pidof", Path.GetFileName (processName)).Result; + if (!success) { + Action logger = quiet ? log.DebugLine : log.ErrorLine; + logger ($"Failed to obtain PID of process '{processName}'"); + logger (output); + return -1; + } + + output = output.Trim (); + if (!UInt32.TryParse (output, out uint pid)) { + if (!quiet) { + log.ErrorLine ($"Unable to parse string '{output}' as the package's PID"); + } + return -1; + } + + return pid; + } + + bool DetectTools () + { + // Not all versions of Android have the `which` utility, all of them have `whence` + // Also, API 21 adbd will not return an error code to us... But since we know that 21 + // doesn't have LDD, we'll cheat + deviceLdd = null; + if (apiLevel > 21) { + (bool success, string output) = adb.Shell ("whence", "ldd").Result; + if (success) { + log.DebugLine ($"Found `ldd` on device at '{output}'"); + deviceLdd = output; + } + } + + if (String.IsNullOrEmpty (deviceLdd)) { + log.DebugLine ("`ldd` not found on device"); + } + + return true; + } + + bool DetermineAppDataDirectory () + { + (bool success, string output) = adb.GetAppDataDirectory (packageName).Result; + if (!AppDataDirFound (success, output)) { + log.ErrorLine ($"Unable to determine data directory for package '{packageName}'"); + return false; + } + + appDataDir = output.Trim (); + + appLldbBaseDir = $"{appDataDir}/lldb"; + appLldbBinDir = $"{appLldbBaseDir}/bin"; + appLldbLogDir = $"{appLldbBaseDir}/log"; + appLldbTmpDir = $"{appLldbBaseDir}/tmp"; + + // Applications with minSdkVersion >= 24 will have their data directories + // created with rwx------ permissions, preventing adbd from forwarding to + // the gdbserver socket. To be safe, if we're on a device >= 24, always + // chmod the directory. + if (apiLevel >= 24) { + (success, output) = adb.RunAs (packageName, "/system/bin/chmod", "a+x", appDataDir).Result; + if (!success) { + log.ErrorLine ("Failed to make application data directory world executable"); + return false; + } + } + + return true; + + bool AppDataDirFound (bool success, string output) + { + if (apiLevel > 21) { + return success; + } + + if (output.IndexOf ("run-as: Package", StringComparison.OrdinalIgnoreCase) >= 0 && + output.IndexOf ("is unknown", StringComparison.OrdinalIgnoreCase) >= 0) + { + return false; + } + + return true; + } + } + + bool DetermineArchitectureAndABI () + { + string? propValue = GetFirstFoundPropertyValue (abiProperties); + deviceABIs = propValue?.Split (','); + + if (deviceABIs == null || deviceABIs.Length == 0) { + log.ErrorLine ("Unable to determine device ABI"); + return false; + } + + bool gotValidAbi = false; + var possibleAbis = new List (); + var possibleArches = new List (); + + foreach (string deviceABI in deviceABIs) { + foreach (string appABI in supportedAbis) { + if (String.Compare (appABI, deviceABI, StringComparison.OrdinalIgnoreCase) == 0) { + string arch = AbiToArch (deviceABI); + + if (!gotValidAbi) { + mainAbi = deviceABI; + mainArch = arch; + appIs64Bit = mainAbi.IndexOf ("64", StringComparison.Ordinal) >= 0; + gotValidAbi = true; + } + + possibleAbis.Add (deviceABI); + possibleArches.Add (arch); + } + } + } + + if (!gotValidAbi) { + log.ErrorLine ($"Application cannot run on the selected device: no matching ABI found"); + } + + availableAbis = possibleAbis.ToArray (); + availableArches = possibleArches.ToArray (); + return gotValidAbi; + + string AbiToArch (string abi) => abi switch { + "armeabi" => "arm", + "armeabi-v7a" => "arm", + "arm64-v8a" => "arm64", + _ => abi, + }; + } + + string? GetFirstFoundPropertyValue (string[] propertyNames) + { + foreach (string prop in propertyNames) { + (bool success, string value) = adb.GetPropertyValue (prop).Result; + if (!success) { + continue; + } + + return value; + } + + return null; + } +} diff --git a/tools/xadebug/Xamarin.Android.Debug/AndroidNdk.cs b/tools/xadebug/Xamarin.Android.Debug/AndroidNdk.cs new file mode 100644 index 00000000000..b75c188d388 --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Debug/AndroidNdk.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Xamarin.Android.Utilities; +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Debug; + +class AndroidNdk +{ + // We want the shell/batch scripts first, since they set up Python environment for the debugger + static readonly string[] lldbNames = { + "lldb.sh", + "lldb", + "lldb.cmd", + "lldb.exe", + }; + + Dictionary hostLldbServerPaths; + XamarinLoggingHelper log; + string? lldbPath; + + public string LldbPath => lldbPath ?? String.Empty; + + public AndroidNdk (XamarinLoggingHelper log, string ndkRootPath, List supportedAbis) + { + this.log = log; + hostLldbServerPaths = new Dictionary (StringComparer.Ordinal); + + if (!FindTools (ndkRootPath, supportedAbis)) { + throw new InvalidOperationException ("Failed to find all the required NDK tools"); + } + } + + public string? GetDebugServerPath (string abi) + { + if (!hostLldbServerPaths.TryGetValue (abi, out string? debugServerPath) || String.IsNullOrEmpty (debugServerPath)) { + log.ErrorLine ($"Debug server for abi '{abi}' not found."); + return null; + } + + return debugServerPath; + } + + bool FindTools (string ndkRootPath, List supportedAbis) + { + string toolchainDir = Path.Combine (ndkRootPath, NdkHelper.RelativeToolchainDir); + string toolchainBinDir = Path.Combine (toolchainDir, "bin"); + string? path = null; + + foreach (string lldb in lldbNames) { + path = Path.Combine (toolchainBinDir, lldb); + if (File.Exists (path)) { + break; + } + } + + if (String.IsNullOrEmpty (path)) { + log.ErrorLine ($"Unable to locate lldb executable in '{toolchainBinDir}'"); + return false; + } + lldbPath = path; + + hostLldbServerPaths = new Dictionary (StringComparer.OrdinalIgnoreCase); + string llvmVersion = GetLlvmVersion (toolchainDir); + foreach (string abi in supportedAbis) { + string llvmAbi = NdkHelper.TranslateAbiToLLVM (abi); + path = Path.Combine (ndkRootPath, NdkHelper.RelativeToolchainDir, "lib64", "clang", llvmVersion, "lib", "linux", llvmAbi, "lldb-server"); + if (!File.Exists (path)) { + log.ErrorLine ($"LLVM lldb server component for ABI '{abi}' not found at '{path}'"); + return false; + } + + hostLldbServerPaths.Add (abi, path); + } + + if (hostLldbServerPaths.Count == 0) { + log.ErrorLine ("Unable to find any lldb-server executables, debugging not possible"); + return false; + } + + return true; + } + + string GetLlvmVersion (string toolchainDir) + { + string path = Path.Combine (toolchainDir, "AndroidVersion.txt"); + if (!File.Exists (path)) { + throw new InvalidOperationException ($"LLVM version file not found at '{path}'"); + } + + string[] lines = File.ReadAllLines (path); + string? line = lines.Length >= 1 ? lines[0].Trim () : null; + if (String.IsNullOrEmpty (line)) { + throw new InvalidOperationException ($"Unable to read LLVM version from '{path}'"); + } + + return line; + } +} diff --git a/tools/xadebug/Xamarin.Android.Debug/ApplicationInfo.cs b/tools/xadebug/Xamarin.Android.Debug/ApplicationInfo.cs new file mode 100644 index 00000000000..1cfbe3e00e1 --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Debug/ApplicationInfo.cs @@ -0,0 +1,21 @@ +using System; + +namespace Xamarin.Android.Debug; + +class ApplicationInfo +{ + public string PackageName { get; } + public uint MinSdkVersion { get; } + public bool Debuggable { get; set; } + public string? Activity { get; set; } + + public ApplicationInfo (string packageName, string minSdkVersion) + { + PackageName = packageName; + + if (!UInt32.TryParse (minSdkVersion, out uint ver)) { + throw new ArgumentException ($"Unable to parse minimum SDK version from '{minSdkVersion}'", nameof (minSdkVersion)); + } + MinSdkVersion = ver; + } +} diff --git a/tools/xadebug/Xamarin.Android.Debug/DebugSession.cs b/tools/xadebug/Xamarin.Android.Debug/DebugSession.cs new file mode 100644 index 00000000000..d817d5ac851 --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Debug/DebugSession.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Xamarin.Android.Utilities; +using Xamarin.Tools.Zip; + +namespace Xamarin.Android.Debug; + +class DebugSession +{ + static readonly Dictionary KnownAbiDirs = new Dictionary (StringComparer.Ordinal) { + { "lib/arm64-v8a", "arm64-v8a" }, + { "lib/armeabi-v7a", "armeabi-v7a" }, + { "lib/x86_64", "x86_64" }, + { "lib/x86", "x86" }, + }; + + readonly string apkPath; + readonly ParsedOptions parsedOptions; + readonly XamarinLoggingHelper log; + readonly ZipArchive apk; + readonly string workDirectory; + readonly ApplicationInfo appInfo; + + const string socketScheme = "unix-abstract"; + string? socketDir = null; + string? socketName = null; + string? lldbScriptPath = null; + AndroidDevice? device = null; + AndroidNdk? ndk = null; + + public DebugSession (XamarinLoggingHelper logger, ApplicationInfo appInfo, string apkPath, ZipArchive apk, ParsedOptions parsedOptions) + { + log = logger; + this.apkPath = apkPath; + this.parsedOptions = parsedOptions; + this.apk = apk; + this.appInfo = appInfo; + workDirectory = Path.Combine (parsedOptions.WorkDirectory, Utilities.StringHash (apkPath)); + } + + public bool Prepare () + { + List supportedAbis = DetectSupportedAbis (); + if (supportedAbis.Count == 0) { + log.ErrorLine ("Unable to detect ABIs supported by the application"); + return false; + } + + ndk = new AndroidNdk (log, parsedOptions.NdkDirPath!, supportedAbis); + device = new AndroidDevice ( + log, // general logger + log, // process output logger + ndk, + workDirectory, + parsedOptions.AdbPath, + appInfo.PackageName, + supportedAbis, + parsedOptions.TargetDevice + ); + + // Install first, since there might already be a version of this application on device that is not debuggable + if (!device.AdbRunner.Install (apkPath, apkIsDebuggable: true).Result) { + log.ErrorLine ($"Failed to install package '{apkPath}' to device"); + return false; + } + + if (!device.GatherInfo ()) { + return false; + } + + string? mainProcessPath; + if (!device.Prepare (out mainProcessPath) || String.IsNullOrEmpty (mainProcessPath)) { + return false; + } + + string appLibsDirectory = Path.Combine (workDirectory, "lib", device.MainAbi); + CopyAppLibs (device, appLibsDirectory); + + log.MessageLine (); + + if (supportedAbis.Count > 0) { + log.StatusLine ("All supported ABIs", String.Join (", ", supportedAbis)); + } + + socketDir = $"/xa-{appInfo.PackageName}"; + + var rnd = new Random (); + socketName = $"xa-platform-{rnd.NextInt64 ()}.sock"; + lldbScriptPath = WriteLldbScript (appLibsDirectory, device, socketScheme, socketDir, socketName, mainProcessPath); + + LogABIs ("Application", supportedAbis); + LogABIs (" Device", device.DeviceAbis); + log.StatusLine (" Selected ABI", $"{device.MainAbi} (architecture: {device.MainArch})"); + log.MessageLine (); + log.StatusLine ("Application data directory on device", device.AppDataDir); + log.StatusLine ("Device serial number", device.SerialNumber); + log.StatusLine ("Debug server path on device", device.DebugServerPath); + log.StatusLine ("Debug server launcher script path on device", device.DebugServerLauncherScriptPath); + log.MessageLine (); + + return true; + + void LogABIs (string which, IEnumerable abis) + { + log.StatusLine ($"{which} ABIs", String.Join (", ", abis)); + } + } + + public bool Run () + { + if (!EnsureValidState (nameof (socketDir), socketDir) || + !EnsureValidState (nameof (socketName), socketName) || + !EnsureValidState (nameof (lldbScriptPath), socketName) || + !EnsureValidState (nameof (device), device) || + !EnsureValidState (nameof (ndk), ndk) + ) { + return false; + } + + log.InfoLine ("Starting lldb server on device"); + + // TODO: either start the server in the background of the remote shell or keep this `RunAs` in the background + (bool success, string output) = device!.AdbRunner.RunAs ( + appInfo.PackageName, + device.DebugServerLauncherScriptPath, + device.LldbBaseDir, + socketScheme, + socketDir!, + socketName!, + "\\\"lldb process:gdb-remote packets\\\"").Result; + + if (!success) { + return false; + } + + // TODO: start the app + // TODO: start the app so that it waits for the debugger (until monodroid_gdb_wait is set) + + return true; + + bool EnsureValidState (string name, T? variable) + { + bool valid; + + if (typeof(T) == typeof(string)) { + valid = !String.IsNullOrEmpty ((string?)(object?)variable); + } else { + valid = variable != null; + } + + if (valid) { + return true; + } + + log.ErrorLine ($"Debug session hasn't been initialized properly. Required variable '{name}' not set"); + return false; + } + } + + string WriteLldbScript (string appLibsDir, AndroidDevice device, string socketScheme, string socketDir, string socketName, string mainProcessPath) + { + string outputFile = Path.Combine (workDirectory, "lldb.x"); + string fullLibsDir = Path.GetFullPath (appLibsDir); + using FileStream fs = File.Open (outputFile, FileMode.Create, FileAccess.Write, FileShare.Read); + using StreamWriter sw = new StreamWriter (fs, Utilities.UTF8NoBOM); + + // TODO: add support for appending user commands + var searchPathsList = new List { + $"\"{fullLibsDir}\"" + }; + + string searchPaths = String.Join (" ", searchPathsList); + sw.WriteLine ($"settings append target.exec-search-paths {searchPaths}"); + sw.WriteLine ("platform select remote-android"); + sw.WriteLine ($"platform connect {socketScheme}-connect://{socketDir}/{socketName}"); + sw.WriteLine ($"file \"{mainProcessPath}\""); + + log.DebugLine ($"Writing LLDB startup script: {outputFile}"); + sw.Flush (); + + return outputFile; + } + + void CopyAppLibs (AndroidDevice device, string libDir) + { + log.DebugLine ($"Copying application shared libraries to '{libDir}'"); + Directory.CreateDirectory (libDir); + + string entryDir = $"lib/{device.MainAbi}/"; + log.DebugLine ($"Looking for shared libraries inside APK, stored in the {entryDir} directory"); + + foreach (ZipEntry entry in apk) { + if (entry.IsDirectory) { + continue; + } + + string dirName = Utilities.GetZipEntryDirName (entry.FullName); + if (dirName.Length == 0 || String.Compare (entryDir, dirName, StringComparison.Ordinal) != 0) { + continue; + } + + string destPath = Path.Combine (libDir, Utilities.GetZipEntryFileName (entry.FullName)); + log.DebugLine ($"Copying app library '{entry.FullName}' to '{destPath}'"); + + using var libraryData = File.Open (destPath, FileMode.Create); + entry.Extract (libraryData); + libraryData.Seek (0, SeekOrigin.Begin); + libraryData.Flush (); + libraryData.Close (); + + // TODO: fetch symbols for libs which don't start with `libaot*` and aren't known Xamarin.Android libraries + } + } + + List DetectSupportedAbis () + { + var ret = new List (); + + log.DebugLine ($"Detecting ABIs supported by '{apkPath}'"); + HashSet seenAbis = new HashSet (StringComparer.Ordinal); + foreach (ZipEntry entry in apk) { + if (seenAbis.Count == KnownAbiDirs.Count) { + break; + } + + // There might not be separate entries for lib/{ARCH} directories, so we look for the first file + // inside one of them to determine if an ABI is supported + string entryDir = Path.GetDirectoryName (entry.FullName) ?? String.Empty; + if (!KnownAbiDirs.TryGetValue (entryDir, out string? abi) || seenAbis.Contains (abi)) { + continue; + } + + seenAbis.Add (abi); + ret.Add (abi); + } + + return ret; + } +} diff --git a/tools/xadebug/Xamarin.Android.Debug/DeviceLibrariesCopier.cs b/tools/xadebug/Xamarin.Android.Debug/DeviceLibrariesCopier.cs new file mode 100644 index 00000000000..73982bfa919 --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Debug/DeviceLibrariesCopier.cs @@ -0,0 +1,79 @@ +using System; + +using Xamarin.Android.Utilities; +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Debug; + +abstract class DeviceLibraryCopier +{ + protected XamarinLoggingHelper Log { get; } + protected bool AppIs64Bit { get; } + protected string LocalDestinationDir { get; } + protected AdbRunner2 Adb { get; } + protected AndroidDevice Device { get; } + + protected DeviceLibraryCopier (XamarinLoggingHelper log, AdbRunner2 adb, bool appIs64Bit, string localDestinationDir, AndroidDevice device) + { + Log = log; + Adb = adb; + AppIs64Bit = appIs64Bit; + LocalDestinationDir = localDestinationDir; + Device = device; + } + + protected string? FetchZygote () + { + string zygotePath; + string destination; + + if (AppIs64Bit) { + zygotePath = "/system/bin/app_process64"; + destination = Utilities.MakeLocalPath (LocalDestinationDir, zygotePath); + + Utilities.MakeFileDirectory (destination); + if (!Adb.Pull (zygotePath, destination).Result) { + return ReportFailureAndReturn (); + } + } else { + // /system/bin/app_process is 32-bit on 32-bit devices, but a symlink to + // app_process64 on 64-bit. If we need the 32-bit version, try to pull + // app_process32, and if that fails, pull app_process. + destination = Utilities.MakeLocalPath (LocalDestinationDir, "/system/bin/app_process"); + string? source = "/system/bin/app_process32"; + + Utilities.MakeFileDirectory (destination); + if (!Adb.Pull (source, destination).Result) { + source = "/system/bin/app_process"; + if (!Adb.Pull (source, destination).Result) { + source = null; + } + } + + if (String.IsNullOrEmpty (source)) { + return ReportFailureAndReturn (); + } + + zygotePath = destination; + } + + Log.DebugLine ($"Zygote path: {zygotePath}"); + return zygotePath; + + string? ReportFailureAndReturn () + { + const string appProcess32 = "app_process"; + const string appProcess64 = appProcess32 + "64"; + + string bitness = AppIs64Bit ? "64" : "32"; + string process = AppIs64Bit ? appProcess64 : appProcess32; + + Log.ErrorLine ($"Failed to copy {bitness}-bit {process}"); + Log.ErrorLine ("Unable to determine path of the zygote process on device"); + + return null; + } + } + + public abstract bool Copy (out string? zygotePath); +} diff --git a/tools/xadebug/Xamarin.Android.Debug/LdConfigParser.cs b/tools/xadebug/Xamarin.Android.Debug/LdConfigParser.cs new file mode 100644 index 00000000000..983ae60e5d4 --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Debug/LdConfigParser.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Xamarin.Android.Utilities; + +namespace Xamarin.Android.Debug; + +class LdConfigParser +{ + XamarinLoggingHelper log; + + public LdConfigParser (XamarinLoggingHelper log) + { + this.log = log; + } + + // Format: https://android.googlesource.com/platform/bionic/+/master/linker/ld.config.format.md + // + public (List searchPaths, HashSet permittedPaths) Parse (string localLdConfigPath, string deviceBinDirectory, string libDirName) + { + var searchPaths = new List (); + var permittedPaths = new HashSet (); + bool foundSomeSection = false; + bool insideMatchingSection = false; + string normalizedDeviceBinDirectory = Utilities.NormalizeDirectoryPath (deviceBinDirectory); + string? sectionName = null; + + log.DebugLine ($"Parsing LD config file '{localLdConfigPath}'"); + int lineCounter = 0; + var namespaces = new List { + "default" + }; + + foreach (string l in File.ReadLines (localLdConfigPath)) { + lineCounter++; + string line = l.Trim (); + if (line.Length == 0 || line.StartsWith ('#')) { + continue; + } + + // The `dir.*` entries are before any section, don't waste time looking for them if we've parsed a section already + if (!foundSomeSection && sectionName == null) { + sectionName = GetMatchingDirMapping (normalizedDeviceBinDirectory, line); + if (sectionName != null) { + log.DebugLine ($"Found section name on line {lineCounter}: '{sectionName}'"); + continue; + } + } + + if (line[0] == '[') { + foundSomeSection = true; + insideMatchingSection = String.Compare (line, $"[{sectionName}]", StringComparison.Ordinal) == 0; + if (insideMatchingSection) { + log.DebugLine ($"Found section '{sectionName}' start on line {lineCounter}"); + } + } + + if (!insideMatchingSection) { + continue; + } + + if (line.StartsWith ("additional.namespaces", StringComparison.Ordinal) && GetVariableAssignmentParts (line, out string? name, out string? value)) { + foreach (string v in value!.Split (',')) { + string nsName = v.Trim (); + if (nsName.Length == 0) { + continue; + } + + log.DebugLine ($"Adding additional namespace '{nsName}'"); + namespaces.Add (nsName); + } + continue; + } + + MaybeAddLibraryPath (searchPaths, permittedPaths, namespaces, line, libDirName); + } + + return (searchPaths, permittedPaths); + + } + + void MaybeAddLibraryPath (List searchPaths, HashSet permittedPaths, List knownNamespaces, string configLine, string libDirName) + { + if (!configLine.StartsWith ("namespace.", StringComparison.Ordinal)) { + return; + } + + // not interested in ASAN libraries + if (configLine.IndexOf (".asan.", StringComparison.Ordinal) > 0) { + return; + } + + foreach (string ns in knownNamespaces) { + if (!GetVariableAssignmentParts (configLine, out string? name, out string? value)) { + continue; + } + + string varName = $"namespace.{ns}.search.paths"; + if (String.Compare (varName, name, StringComparison.Ordinal) == 0) { + AddPath (searchPaths, "search", value!); + continue; + } + + varName = $"namespace.{ns}.permitted.paths"; + if (String.Compare (varName, name, StringComparison.Ordinal) == 0) { + AddPath (permittedPaths, "permitted", value!, checkIfAlreadyAdded: true); + } + } + + void AddPath (ICollection list, string which, string value, bool checkIfAlreadyAdded = false) + { + string path = Utilities.NormalizeDirectoryPath (value.Replace ("${LIB}", libDirName)); + + if (checkIfAlreadyAdded && list.Contains (path)) { + return; + } + + log.DebugLine ($"Adding library {which} path: {path}"); + list.Add (path); + } + } + + string? GetMatchingDirMapping (string deviceBinDirectory, string configLine) + { + const string LinePrefix = "dir."; + + string line = configLine.Trim (); + if (line.Length == 0 || !line.StartsWith (LinePrefix, StringComparison.Ordinal)) { + return null; + } + + if (!GetVariableAssignmentParts (line, out string? name, out string? value)) { + return null; + } + + string dirPath = Utilities.NormalizeDirectoryPath (value!); + if (String.Compare (dirPath, deviceBinDirectory, StringComparison.Ordinal) != 0) { + return null; + } + + string ns = name!.Substring (LinePrefix.Length).Trim (); + if (String.IsNullOrEmpty (ns)) { + return null; + } + + return ns; + } + + bool GetVariableAssignmentParts (string line, out string? name, out string? value) + { + name = value = null; + + string[] parts = line.Split ("+=", 2); + if (parts.Length != 2) { + parts = line.Split ('=', 2); + if (parts.Length != 2) { + return false; + } + } + + name = parts[0].Trim (); + value = parts[1].Trim (); + + return true; + } +} diff --git a/tools/xadebug/Xamarin.Android.Debug/LddDeviceLibrariesCopier.cs b/tools/xadebug/Xamarin.Android.Debug/LddDeviceLibrariesCopier.cs new file mode 100644 index 00000000000..166b611ecd1 --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Debug/LddDeviceLibrariesCopier.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; + +using Xamarin.Android.Utilities; + +namespace Xamarin.Android.Debug; + +class LddDeviceLibraryCopier : DeviceLibraryCopier +{ + public LddDeviceLibraryCopier (XamarinLoggingHelper log, AdbRunner2 adb, bool appIs64Bit, string localDestinationDir, AndroidDevice device) + : base (log, adb, appIs64Bit, localDestinationDir, device) + {} + + public override bool Copy (out string? zygotePath) + { + string lddPath = Device.DeviceLddPath ?? throw new InvalidOperationException ("On-device `ldd` binary is required"); + + zygotePath = FetchZygote (); + if (String.IsNullOrEmpty (zygotePath)) { + return false; + } + + (bool success, string zygoteLibs) = Adb.Shell (lddPath, zygotePath).Result; + if (!success) { + Log.ErrorLine ($"On-device ldd ({lddPath}) failed to return list of dependencies for Android application process {zygotePath}"); + return false; + } + + Log.DebugLine ("Zygote libs:"); + Log.DebugLine (zygoteLibs); + + (List libraryPaths, List libraryNames) = LddOutputToLibraryList (zygoteLibs); + if (libraryPaths.Count == 0 || libraryNames.Count == 0) { + Log.WarningLine ($"ldd didn't report any shared libraries on-device application process '{zygotePath}' depends on"); + return true; // Not an error, per se + } + + var moduleCache = new LddLldbModuleCache (Log, Device, libraryPaths, libraryNames); + moduleCache.Populate (zygotePath); + + return true; + } + + (List libraryPaths, List libraryNames) LddOutputToLibraryList (string output) + { + var libraryPaths = new List (); + var libraryNames = new List (); + if (String.IsNullOrEmpty (output)) { + return (libraryPaths, libraryNames); + } + + // Overall line format is: LIBRARY_NAME => LIBRARY_PATH (HEX_ADDRESS) + // Lines are split on space, in assumption (and hope) that Android will not use filenames with spaces in them. This way we don't have to worry about the `=>` + // separator (which can, in theory, be changed to something else on some version of Android) + foreach (string l in output.Split ('\n')) { + string line = l.Trim (); + if (line.Length == 0) { + continue; + } + + string[] parts = line.Split (' '); + if (parts.Length != 4) { + Log.WarningLine ($"ldd line has unsupported format, ignoring: '{line}'"); + continue; + } + + string path = parts[2]; + if (String.Compare (path, "[vdso]", StringComparison.OrdinalIgnoreCase) == 0) { + // virtual library, doesn't exist on disk + continue; + } + + libraryPaths.Add (path); + libraryNames.Add (parts[0]); + } + + return (libraryPaths, libraryNames); + } +} diff --git a/tools/xadebug/Xamarin.Android.Debug/LddLldbModuleCache.cs b/tools/xadebug/Xamarin.Android.Debug/LddLldbModuleCache.cs new file mode 100644 index 00000000000..edf833cf536 --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Debug/LddLldbModuleCache.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +using Xamarin.Android.Utilities; + +namespace Xamarin.Android.Debug; + +class LddLldbModuleCache : LldbModuleCache +{ + List dependencyLibraryNames; + + public LddLldbModuleCache (XamarinLoggingHelper log, AndroidDevice device, List deviceSharedLibraryPaths, List dependencyLibraryNames) + : base (log, device, deviceSharedLibraryPaths) + { + this.dependencyLibraryNames = dependencyLibraryNames; + } + + protected override void FetchDependencies (HashSet alreadyDownloaded, string localPath) + { + Log.DebugLine ($"Binary {localPath} references the following libraries:"); + foreach (string lib in dependencyLibraryNames) { + FetchLibrary (lib, alreadyDownloaded); + } + } +} diff --git a/tools/xadebug/Xamarin.Android.Debug/LldbModuleCache.cs b/tools/xadebug/Xamarin.Android.Debug/LldbModuleCache.cs new file mode 100644 index 00000000000..1d58b0d5c6d --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Debug/LldbModuleCache.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Xamarin.Android.Utilities; + +namespace Xamarin.Android.Debug; + +abstract class LldbModuleCache +{ + List deviceSharedLibraries; + Dictionary libraryCache; + + protected AndroidDevice Device { get; } + protected string CacheDirPath { get; } + protected XamarinLoggingHelper Log { get; } + + protected LldbModuleCache (XamarinLoggingHelper log, AndroidDevice device, List deviceSharedLibraries) + { + Device = device; + Log = log; + + CacheDirPath = Path.Combine ( + Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), + ".lldb", + "module_cache", + "remote-android", // "platform" used by LLDB in our case + device.SerialNumber + ); + + this.deviceSharedLibraries = deviceSharedLibraries; + libraryCache = new Dictionary (StringComparer.Ordinal); + } + + public void Populate (string zygotePath) + { + string? localPath = FetchFileFromDevice (zygotePath); + if (localPath == null) { + // TODO: should we perhaps fetch a set of "basic" libraries here, as a fallback? + Log.WarningLine ($"Unable to fetch Android application launcher binary ('{zygotePath}') from device. No cache of shared modules will be generated"); + return; + } + + var alreadyDownloaded = new HashSet (StringComparer.Ordinal); + FetchDependencies (alreadyDownloaded, localPath); + } + + protected abstract void FetchDependencies (HashSet alreadyDownloaded, string localPath); + + protected string? FetchLibrary (string lib, HashSet alreadyDownloaded) + { + Log.Debug ($" {lib}"); + if (alreadyDownloaded.Contains (lib)) { + Log.DebugLine (" [already downloaded]"); + return null; + } + + string? deviceLibraryPath = GetSharedLibraryPath (lib); + if (String.IsNullOrEmpty (deviceLibraryPath)) { + Log.DebugLine (" [device path unknown]"); + Log.WarningLine ($"Referenced libary '{lib}' not found on device"); + return null; + } + + Log.DebugLine (" [downloading]"); + Log.Status ("Downloading", deviceLibraryPath); + string? localLibraryPath = FetchFileFromDevice (deviceLibraryPath); + if (String.IsNullOrEmpty (localLibraryPath)) { + Log.Log (LogLevel.Info, " [FAILED]", XamarinLoggingHelper.ErrorColor); + return null; + } + Log.LogLine (LogLevel.Info, " [SUCCESS]", XamarinLoggingHelper.InfoColor); + + alreadyDownloaded.Add (lib); + + return localLibraryPath; + } + + protected string? FetchFileFromDevice (string deviceFilePath) + { + string localFilePath = Utilities.MakeLocalPath (CacheDirPath, deviceFilePath); + string localTempFilePath = $"{localFilePath}.tmp"; + + Directory.CreateDirectory (Path.GetDirectoryName (localFilePath)!); + + if (!Device.AdbRunner.Pull (deviceFilePath, localTempFilePath).Result) { + Log.ErrorLine ($"Failed to download {deviceFilePath} from the attached device"); + return null; + } + + File.Move (localTempFilePath, localFilePath, true); + return localFilePath; + } + + string? GetSharedLibraryPath (string libraryName) + { + if (libraryCache.TryGetValue (libraryName, out string? libraryPath)) { + return libraryPath; + } + + foreach (string libPath in deviceSharedLibraries) { + string fileName = Utilities.GetZipEntryFileName (libPath); + + if (String.Compare (libraryName, fileName, StringComparison.Ordinal) == 0) { + libraryCache.Add (libraryName, libPath); + return libPath; + } + } + + // Cache misses, too, the list isn't going to change + libraryCache.Add (libraryName, null); + return null; + } +} diff --git a/tools/xadebug/Xamarin.Android.Debug/NoLddDeviceLibrariesCopier.cs b/tools/xadebug/Xamarin.Android.Debug/NoLddDeviceLibrariesCopier.cs new file mode 100644 index 00000000000..646891d819a --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Debug/NoLddDeviceLibrariesCopier.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Xamarin.Android.Utilities; +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Debug; + +class NoLddDeviceLibraryCopier : DeviceLibraryCopier +{ + const string LdConfigPath = "/system/etc/ld.config.txt"; + + // To make things interesting, it turns out that API29 devices have **both** API 28 and 29 (they report 28) and for that reason they have TWO config files for ld... + const string LdConfigPath28 = "/etc/ld.config.28.txt"; + const string LdConfigPath29 = "/etc/ld.config.29.txt"; + + // TODO: We probably need a "provider" for the list of paths, since on ARM devices, /system/lib{64} directories contain x86/x64 binaries, and the ARM binaries are found in + // /system/lib{64]/arm{64} (but not on all devices, of course... e.g. Pixel 6 Pro doesn't have these) + // + // List of directory paths to use when the device has neither ldd nor /system/etc/ld.config.txt + static readonly string[] FallbackLibraryDirectories = { + "/system/@LIB@", + "/system/@LIB@/drm", + "/system/@LIB@/egl", + "/system/@LIB@/hw", + "/system/@LIB@/soundfx", + "/system/@LIB@/ssl", + "/system/@LIB@/ssl/engines", + + // /system/vendor is a symlink to /vendor on some Android versions, we'll skip the latter then + "/system/vendor/@LIB@", + "/system/vendor/@LIB@/egl", + "/system/vendor/@LIB@/mediadrm", + }; + + public NoLddDeviceLibraryCopier (XamarinLoggingHelper log, AdbRunner2 adb, bool appIs64Bit, string localDestinationDir, AndroidDevice device) + : base (log, adb, appIs64Bit, localDestinationDir, device) + {} + + public override bool Copy (out string? zygotePath) + { + zygotePath = FetchZygote (); + if (String.IsNullOrEmpty (zygotePath)) { + return false; + } + + (List searchPaths, HashSet permittedPaths) = GetLibraryPaths (); + + // Collect file listings for all the search directories + var sharedLibraries = new List (); + foreach (string path in searchPaths) { + AddSharedLibraries (sharedLibraries, path, permittedPaths); + } + + var moduleCache = new NoLddLldbModuleCache (Log, Device, sharedLibraries); + moduleCache.Populate (zygotePath); + + return true; + } + + void AddSharedLibraries (List sharedLibraries, string deviceDirPath, HashSet permittedPaths) + { + AdbRunner2.OutputLineFilter filterOutErrors = (bool isStdError, string line) => { + if (!isStdError) { + return false; // don't suppress any lines on stdout + } + + // Ignore these, since we don't really care and there's no point in spamming the output with red + return + line.IndexOf ("Permission denied", StringComparison.OrdinalIgnoreCase) >= 0 || + line.IndexOf ("No such file or directory", StringComparison.OrdinalIgnoreCase) >= 0; + }; + + (bool success, string output) = Adb.Shell (filterOutErrors, "ls", "-l", deviceDirPath).Result; + if (!success) { + // We can't rely on `success` because `ls -l` will return an error code if the directory exists but has any entries access to whose is not permitted + if (output.IndexOf ("No such file or directory", StringComparison.OrdinalIgnoreCase) >= 0) { + Log.DebugLine ($"Shared libraries directory {deviceDirPath} not found on device"); + return; + } + } + + Log.DebugLine ($"Adding shared libraries from {deviceDirPath}"); + foreach (string l in output.Split ('\n')) { + string line = l.Trim (); + if (line.Length == 0) { + continue; + } + + // `ls -l` output has 8 columns for filesystem entries + string[] parts = line.Split (' ', 8, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 8) { + continue; + } + + string permissions = parts[0].Trim (); + string name = parts[7].Trim (); + + // First column, permissions: `drwxr-xr-x`, `-rw-r--r--` etc + if (permissions[0] == 'd') { + // Directory + string nestedDirPath = $"{deviceDirPath}{name}/"; + if (permittedPaths.Count > 0 && !permittedPaths.Contains (nestedDirPath)) { + Log.DebugLine ($"Directory '{nestedDirPath}' is not in the list of permitted directories, ignoring"); + continue; + } + + AddSharedLibraries (sharedLibraries, nestedDirPath, permittedPaths); + continue; + } + + // Ignore entries that aren't regular .so files or symlinks + if ((permissions[0] != '-' && permissions[0] != 'l') || !name.EndsWith (".so", StringComparison.Ordinal)) { + continue; + } + + string libPath; + if (permissions[0] == 'l') { + // Let's hope there are no libraries with -> in their name :P (if there are, we should use `readlink`) + const string SymlinkArrow = "->"; + + // Symlink, we'll add the target library instead + int idx = name.IndexOf (SymlinkArrow, StringComparison.Ordinal); + if (idx > 0) { + libPath = name.Substring (idx + SymlinkArrow.Length).Trim (); + } else { + Log.WarningLine ($"'ls -l' output line contains a symbolic link, but I can't determine the target:"); + Log.WarningLine ($" '{line}'"); + Log.WarningLine ("Ignoring this entry"); + continue; + } + } else { + libPath = $"{deviceDirPath}{name}"; + } + + Log.DebugLine ($" {libPath}"); + sharedLibraries.Add (libPath); + } + } + + (List searchPaths, HashSet permittedPaths) GetLibraryPaths () + { + string lib = AppIs64Bit ? "lib64" : "lib"; + + if (Device.ApiLevel == 21) { + // API21 devices (at least emulators) don't return adb error codes, so to avoid awkward error message parsing, we're going to just skip detection since we + // know what API21 has and doesn't have + return (GetFallbackDirs (), new HashSet ()); + } + + string localLdConfigPath = Utilities.MakeLocalPath (LocalDestinationDir, LdConfigPath); + Utilities.MakeFileDirectory (localLdConfigPath); + + string deviceLdConfigPath; + + if (Device.ApiLevel == 28) { + deviceLdConfigPath = LdConfigPath28; + } else if (Device.ApiLevel == 29) { + deviceLdConfigPath = LdConfigPath29; + } else { + deviceLdConfigPath = LdConfigPath; + } + + if (!Adb.Pull (deviceLdConfigPath, localLdConfigPath).Result) { + Log.DebugLine ($"Device doesn't have {LdConfigPath}"); + return (GetFallbackDirs (), new HashSet ()); + } else { + Log.DebugLine ($"Downloaded {deviceLdConfigPath} to {localLdConfigPath}"); + } + + var parser = new LdConfigParser (Log); + + // The app executables (app_process and app_process32) are both in /system/bin, so we can limit our + // library search paths to this location. + (List searchPaths, HashSet permittedPaths) = parser.Parse (localLdConfigPath, "/system/bin", lib); + if (searchPaths.Count == 0) { + searchPaths = GetFallbackDirs (); + } + + return (searchPaths, permittedPaths); + + List GetFallbackDirs () + { + Log.DebugLine ("Using fallback library directories for this device"); + return FallbackLibraryDirectories.Select (l => l.Replace ("@LIB@", lib)).ToList (); + } + } +} diff --git a/tools/xadebug/Xamarin.Android.Debug/NoLddLldbModuleCache.cs b/tools/xadebug/Xamarin.Android.Debug/NoLddLldbModuleCache.cs new file mode 100644 index 00000000000..4cd03dcdac7 --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Debug/NoLddLldbModuleCache.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; + +using ELFSharp.ELF; +using ELFSharp.ELF.Sections; +using Xamarin.Android.Utilities; + +namespace Xamarin.Android.Debug; + +class NoLddLldbModuleCache : LldbModuleCache +{ + public NoLddLldbModuleCache (XamarinLoggingHelper log, AndroidDevice device, List deviceSharedLibraries) + : base (log, device, deviceSharedLibraries) + {} + + protected override void FetchDependencies (HashSet alreadyDownloaded, string localPath) + { + using IELF? elf = ReadElfFile (localPath); + FetchDependencies (elf, alreadyDownloaded, localPath); + } + + void FetchDependencies (IELF? elf, HashSet alreadyDownloaded, string localPath) + { + if (elf == null) { + Log.DebugLine ($"Failed to open '{localPath}' as an ELF file. Ignoring."); + return; + } + + var dynstr = GetSection (elf, ".dynstr") as IStringTable; + if (dynstr == null) { + Log.DebugLine ($"ELF binary {localPath} has no .dynstr section, unable to read referenced shared library names"); + return; + } + + var needed = new HashSet (StringComparer.Ordinal); + foreach (IDynamicSection section in elf.GetSections ()) { + foreach (IDynamicEntry entry in section.Entries) { + if (entry.Tag != DynamicTag.Needed) { + continue; + } + + AddNeeded (dynstr, entry); + } + } + + Log.DebugLine ($"Binary {localPath} references the following libraries:"); + foreach (string lib in needed) { + string? localLibraryPath = FetchLibrary (lib, alreadyDownloaded); + if (String.IsNullOrEmpty (localLibraryPath)) { + continue; + } + + using IELF? libElf = ReadElfFile (localLibraryPath); + FetchDependencies (libElf, alreadyDownloaded, localLibraryPath); + } + + void AddNeeded (IStringTable stringTable, IDynamicEntry entry) + { + ulong index; + if (entry is DynamicEntry entry64) { + index = entry64.Value; + } else if (entry is DynamicEntry entry32) { + index = (ulong)entry32.Value; + } else { + Log.WarningLine ($"DynamicEntry neither 32 nor 64 bit? Weird"); + return; + } + + string name = stringTable[(long)index]; + if (needed.Contains (name)) { + return; + } + + needed.Add (name); + } + } + + IELF? ReadElfFile (string path) + { + try { + if (ELFReader.TryLoad (path, out IELF ret)) { + return ret; + } + } catch (Exception ex) { + Log.WarningLine ($"{path} may not be a valid ELF binary."); + Log.WarningLine (ex.ToString ()); + } + + return null; + } + + ISection? GetSection (IELF elf, string sectionName) + { + if (!elf.TryGetSection (sectionName, out ISection section)) { + return null; + } + + return section; + } +} diff --git a/tools/xadebug/Xamarin.Android.Debug/Utilities.cs b/tools/xadebug/Xamarin.Android.Debug/Utilities.cs new file mode 100644 index 00000000000..acda6cd6ff2 --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Debug/Utilities.cs @@ -0,0 +1,118 @@ +using System; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; + +using Java.Interop.Tools.JavaCallableWrappers; +using Xamarin.Android.Utilities; + +namespace Xamarin.Android.Debug; + +static class Utilities +{ + public static readonly UTF8Encoding UTF8NoBOM = new UTF8Encoding (false); + + public static bool IsMacOS { get; private set; } + public static bool IsLinux { get; private set; } + public static bool IsWindows { get; private set; } + + static Utilities () + { + if (RuntimeInformation.IsOSPlatform (OSPlatform.Windows)) { + IsWindows = true; + } else if (RuntimeInformation.IsOSPlatform (OSPlatform.OSX)) { + IsMacOS = true; + } else if (RuntimeInformation.IsOSPlatform (OSPlatform.Linux)) { + IsLinux = true; + } + } + + public static void MakeFileDirectory (string filePath) + { + if (String.IsNullOrEmpty (filePath)) { + return; + } + + string? dirName = Path.GetDirectoryName (filePath); + if (String.IsNullOrEmpty (dirName)) { + return; + } + + Directory.CreateDirectory (dirName); + } + + public static string? ReadManifestResource (XamarinLoggingHelper log, string resourceName) + { + using (var from = Assembly.GetExecutingAssembly ().GetManifestResourceStream (resourceName)) { + if (from == null) { + log.ErrorLine ($"Manifest resource '{resourceName}' cannot be loaded"); + return null; + } + + using (var sr = new StreamReader (from)) { + return sr.ReadToEnd (); + } + } + } + + public static string NormalizeDirectoryPath (string dirPath) + { + if (dirPath.EndsWith ('/')) { + return dirPath; + } + + return $"{dirPath}/"; + } + + public static string ToLocalPathFormat (string path) => IsWindows ? path.Replace ("/", "\\") : path; + + public static string MakeLocalPath (string localDirectory, string remotePath) + { + string remotePathLocalFormat = ToLocalPathFormat (remotePath); + if (remotePath[0] == '/') { + return $"{localDirectory}{remotePathLocalFormat}"; + } + + return Path.Combine (localDirectory, remotePathLocalFormat); + } + + public static string StringHash (string input, Encoding? encoding = null) + { + if (encoding == null) { + encoding = UTF8NoBOM; + } + + byte[] hash = Crc64Helper.Compute (encoding.GetBytes (input)); + if (hash.Length == 0) { + return input.GetHashCode ().ToString ("x"); + } + + var sb = new StringBuilder (); + foreach (byte b in hash) { + sb.Append (b.ToString ("x02")); + } + + return sb.ToString (); + } + + public static string GetZipEntryFileName (string zipEntryName) + { + int idx = zipEntryName.LastIndexOf ('/'); + if (idx >= 0 && idx != zipEntryName.Length - 1) { + return zipEntryName.Substring (idx + 1); + } + + return zipEntryName; + } + + public static string GetZipEntryDirName (string zipEntryName) + { + int idx = zipEntryName.LastIndexOf ('/'); + if (idx < 0) { + return String.Empty; + } + + return zipEntryName.Substring (0, idx + 1); + } +} diff --git a/tools/xadebug/Xamarin.Android.Utilities/AdbRunner.cs b/tools/xadebug/Xamarin.Android.Utilities/AdbRunner.cs new file mode 100644 index 00000000000..87d8a9aa57b --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Utilities/AdbRunner.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Xamarin.Android.Utilities; + +class AdbRunner2 : ToolRunner2 +{ + public delegate bool OutputLineFilter (bool isStdErr, string line); + + sealed class CaptureOutputState + { + public OutputLineFilter? LineFilter; + public CaptureProcessOutputLogger? Logger; + } + + sealed class CaptureProcessOutputLogger : IProcessOutputLogger + { + IProcessOutputLogger? wrappedLogger; + OutputLineFilter? lineFilter; + List lines; + string? stderrPrefix; + string? stdoutPrefix; + + public List Lines => lines; + + public IProcessOutputLogger? WrappedLogger => wrappedLogger; + + public string? StdoutPrefix { + get => stdoutPrefix ?? wrappedLogger?.StdoutPrefix ?? String.Empty; + set => stdoutPrefix = value; + } + + public string? StderrPrefix { + get => stderrPrefix ?? wrappedLogger?.StderrPrefix ?? String.Empty; + set => stderrPrefix = value; + } + + public CaptureProcessOutputLogger (IProcessOutputLogger? wrappedLogger, OutputLineFilter? lineFilter = null) + { + this.wrappedLogger = wrappedLogger; + this.lineFilter = lineFilter; + + lines = new List (); + } + + public void WriteStderr (string text, bool writeLine = true) + { + if (LineFiltered (text, isStdError: true)) { + return; + } + + wrappedLogger?.WriteStderr (text, writeLine); + } + + public void WriteStdout (string text, bool writeLine = true) + { + if (LineFiltered (text, isStdError: false)) { + return; + } + + lines.Add (text); + } + + bool LineFiltered (string text, bool isStdError) + { + if (lineFilter == null) { + return false; + } + + return lineFilter (isStdError, text); + } + } + + string[]? initialParams; + + public AdbRunner2 (ILogger logger, IProcessOutputLogger processOutputLogger, string adbPath, string? deviceSerial = null) + : base (adbPath, logger, processOutputLogger) + { + if (!String.IsNullOrEmpty (deviceSerial)) { + initialParams = new string[] { "-s", deviceSerial }; + } + } + + public async Task Pull (string remotePath, string localPath) + { + var runner = CreateAdbRunner (); + runner.AddArgument ("pull"); + runner.AddArgument (remotePath); + runner.AddArgument (localPath); + + return await RunAdbAsync (runner); + } + + public async Task Push (string localPath, string remotePath) + { + var runner = CreateAdbRunner (); + runner.AddArgument ("push"); + runner.AddArgument (localPath); + runner.AddArgument (remotePath); + + return await RunAdbAsync (runner); + } + + public async Task Install (string apkPath, bool apkIsDebuggable = false, bool replaceExisting = true, bool noStreaming = true) + { + var runner = CreateAdbRunner (); + runner.AddArgument ("install"); + + if (replaceExisting) { + runner.AddArgument ("-r"); + } + + if (apkIsDebuggable) { + runner.AddArgument ("-d"); // Allow version code downgrade + } + + if (noStreaming) { + runner.AddArgument ("--no-streaming"); + } + + runner.AddQuotedArgument (apkPath); + + return await RunAdbAsync (runner); + } + + public async Task<(bool success, string output)> GetAppDataDirectory (string packageName) + { + return await RunAs (packageName, "/system/bin/sh", "-c", "pwd"); + } + + public async Task<(bool success, string output)> CreateDirectoryAs (string packageName, string directoryPath) + { + return await RunAs (packageName, "mkdir", "-p", directoryPath); + } + + public async Task<(bool success, string output)> GetPropertyValue (string propertyName) + { + var runner = CreateAdbRunner (); + return await Shell ("getprop", propertyName); + } + + public async Task<(bool success, string output)> RunAs (string packageName, string command, params string[] args) + { + return await Shell ( + "run-as", + RunAsPrepareArgs (packageName, command, args), + lineFilter: null + ); + } + + public void RunAsInBackground (BackgroundProcessManager processManager, string packageName, string command, params string[] args) + { + RunAsInBackground (processManager, null, null, packageName, command, args); + } + + public void RunAsInBackground (BackgroundProcessManager processManager, ProcessRunner2.BackgroundActionCompletionHandler? completionHandler, string packageName, string command, params string[] args) + { + RunAsInBackground (processManager, completionHandler, null, packageName, command, args); + } + + public void RunAsInBackground (BackgroundProcessManager processManager, ProcessRunner2.BackgroundActionCompletionHandler? completionHandler, TimeSpan? processTimeout, string packageName, string command, params string[] args) + { + ShellInBackground ( + processManager, + completionHandler, + processTimeout, + "run-as", + RunAsPrepareArgs (packageName, command, args), + lineFilter: null + ); + } + + IEnumerable RunAsPrepareArgs (string packageName, string command, params string[] args) + { + if (String.IsNullOrEmpty (packageName)) { + throw new ArgumentException ("must not be null or empty", nameof (packageName)); + } + + var shellArgs = new List { + packageName, + command, + }; + + if (args != null && args.Length > 0) { + shellArgs.AddRange (args); + } + + return shellArgs; + } + + public async Task<(bool success, string output)> Shell (string command, List args, OutputLineFilter? lineFilter = null) + { + return await Shell (command, (IEnumerable)args, lineFilter); + } + + public async Task<(bool success, string output)> Shell (string command, params string[] args) + { + return await Shell (command, (IEnumerable)args, lineFilter: null); + } + + public async Task<(bool success, string output)> Shell (OutputLineFilter lineFilter, string command, params string[] args) + { + return await Shell (command, (IEnumerable)args, lineFilter); + } + + async Task<(bool success, string output)> Shell (string command, IEnumerable? args, OutputLineFilter? lineFilter) + { + if (String.IsNullOrEmpty (command)) { + throw new ArgumentException ("must not be null or empty", nameof (command)); + } + + var captureState = new CaptureOutputState { + LineFilter = lineFilter, + }; + + var runner = CreateAdbRunner (captureState); + + runner.AddArgument ("shell"); + runner.AddArgument (command); + runner.AddArguments (args); + + return await CaptureAdbOutput (runner, captureState); + } + + void ShellInBackground (BackgroundProcessManager processManager, ProcessRunner2.BackgroundActionCompletionHandler? completionHandler, TimeSpan? processTimeout, + string command, IEnumerable? args, OutputLineFilter? lineFilter) + { + } + + async Task RunAdbAsync (ProcessRunner2 runner) + { + ProcessStatus status = await runner.RunAsync (); + return status.Success; + } + + async Task<(bool success, string output)> CaptureAdbOutput (ProcessRunner2 runner, CaptureOutputState captureState) + { + ProcessStatus status = await runner.RunAsync (); + + string output = captureState.Logger != null ? String.Join (Environment.NewLine, captureState.Logger.Lines) : String.Empty; + return (status.Success, output); + } + + ProcessRunner2 CreateAdbRunner (CaptureOutputState? state = null) => InitProcessRunner (state, initialParams); + + protected override ProcessRunner2 CreateProcessRunner (IProcessOutputLogger consoleProcessLogger, object? state, params string?[]? initialParams) + { + IProcessOutputLogger outputLogger; + + if (state is CaptureOutputState captureState) { + captureState.Logger = new CaptureProcessOutputLogger (consoleProcessLogger, captureState.LineFilter); + outputLogger = captureState.Logger; + } else { + outputLogger = consoleProcessLogger; + } + + outputLogger.StderrPrefix = "adb> "; + ProcessRunner2 ret = base.CreateProcessRunner (outputLogger, initialParams); + + // Let's make sure all the messages we get are in English, since we need to parse some of them to detect problems + ret.Environment["LANG"] = "C"; + return ret; + } +} diff --git a/tools/xadebug/Xamarin.Android.Utilities/BackgroundProcessManager.cs b/tools/xadebug/Xamarin.Android.Utilities/BackgroundProcessManager.cs new file mode 100644 index 00000000000..3cc81af3f5b --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Utilities/BackgroundProcessManager.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Utilities; + +class BackgroundProcessManager : IDisposable +{ + readonly object runnersLock = new object (); + readonly List runners; + + bool disposed; + + public BackgroundProcessManager () + { + runners = new List (); + Console.CancelKeyPress += ConsoleCanceled; + } + + // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources + ~BackgroundProcessManager () + { + Dispose (disposing: false); + } + + protected virtual void Dispose (bool disposing) + { + if (!disposed) { + if (disposing) { + // TODO: dispose managed state (managed objects) + } + + FinishAllTasks (); + disposed = true; + } + } + + public void Dispose () + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose (disposing: true); + GC.SuppressFinalize (this); + } + + public void RunInBackground (ProcessRunner2 runner, ProcessRunner2.BackgroundActionCompletionHandler? completionHandler) + {} + + void TaskFailed (Task task) + { + } + + void FinishAllTasks () + { + } + + void ConsoleCanceled (object? sender, ConsoleCancelEventArgs args) + { + } +} diff --git a/tools/xadebug/Xamarin.Android.Utilities/DotNetRunner.cs b/tools/xadebug/Xamarin.Android.Utilities/DotNetRunner.cs new file mode 100644 index 00000000000..82b13cff55b --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Utilities/DotNetRunner.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Utilities; + +using Utils = Xamarin.Android.Debug.Utilities; + +class DotNetRunner : ToolRunner +{ + class StreamOutputSink : ToolOutputSink + { + readonly StreamWriter output; + + public StreamOutputSink (XamarinLoggingHelper logger, StreamWriter output) + : base (logger) + { + this.output = output; + } + + public override void WriteLine (string? value) + { + output.WriteLine (value ?? String.Empty); + } + } + + readonly string workDirectory; + + public DotNetRunner (XamarinLoggingHelper logger, string toolPath, string workDirectory) + : base (logger, toolPath) + { + this.workDirectory = workDirectory; + } + + /// + /// Build project at (either path to a project directory or a project file) and return full + /// path to binary build log, if the build succeded, or null otherwise. + /// + public async Task Build (string projectPath, string configuration, params string[] extraArgs) + { + string projectWorkDir = Path.Combine (workDirectory, Utils.StringHash (projectPath)); + + Directory.CreateDirectory (projectWorkDir); + string binlogPath = Path.Combine (projectWorkDir, "build.binlog"); + + var runner = CreateProcessRunner ("build"); + runner. + AddArgument ("-c").AddArgument (configuration). + AddArgument ($"-bl:\"{binlogPath}\""); + + if (extraArgs != null && extraArgs.Length > 0) { + foreach (string arg in extraArgs) { + runner.AddArgument (arg); + } + } + runner.AddQuotedArgument (projectPath); + + if (!await RunDotNet (runner)) { + return null; + } + + //return await ConvertBinlogToText (binlogPath); + return binlogPath; + } + + public async Task ConvertBinlogToText (string binlogPath) + { + if (!File.Exists (binlogPath)) { + Logger.ErrorLine ($"Binlog '{binlogPath}' does not exist, cannot convert to text"); + return null; + } + + bool stdoutEchoOrig = EchoStandardOutput; + ProcessRunner runner; + + try { + EchoStandardOutput = false; + runner = CreateProcessRunner ("msbuild"); + } finally { + EchoStandardOutput = stdoutEchoOrig; + } + + runner. + AddArgument ("-v:diag"). + AddQuotedArgument (binlogPath); + + string logOutput = Path.ChangeExtension (binlogPath, ".txt"); + using var fs = File.Open (logOutput, FileMode.Create); + using var sw = new StreamWriter (fs, Utils.UTF8NoBOM); + using var sink = new StreamOutputSink (Logger, sw); + + try { + if (!await RunDotNet (runner, sink)) { + return null; + } + } finally { + sw.Flush (); + } + + return logOutput; + } + + async Task RunDotNet (ProcessRunner runner, ToolOutputSink? outputSink = null) + { + if (outputSink != null) { + SetupOutputSinks (runner, stdoutSink: outputSink, stderrSink: null, ignoreStderr: true); + } + + return await RunTool (() => runner.Run ()); + } +} diff --git a/tools/xadebug/Xamarin.Android.Utilities/ILogger.cs b/tools/xadebug/Xamarin.Android.Utilities/ILogger.cs new file mode 100644 index 00000000000..cd7838781a8 --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Utilities/ILogger.cs @@ -0,0 +1,22 @@ +using System; + +namespace Xamarin.Android.Utilities; + +interface ILogger +{ + void Message (string? message); + void MessageLine (string? message = null); + void Warning (string? message); + void WarningLine (string? message = null); + void Error (string? message); + void ErrorLine (string? message = null); + void Info (string? message); + void InfoLine (string? message = null); + void Debug (string? message); + void DebugLine (string? message = null); + void Status (string label, string text); + void StatusLine (string label, string text); + void Log (LogLevel level, string? message); + void LogLine (LogLevel level, string? message, ConsoleColor color); + void Log (LogLevel level, string? message, ConsoleColor color); +} diff --git a/tools/xadebug/Xamarin.Android.Utilities/IProcessOutputLogger.cs b/tools/xadebug/Xamarin.Android.Utilities/IProcessOutputLogger.cs new file mode 100644 index 00000000000..3ffb3eda893 --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Utilities/IProcessOutputLogger.cs @@ -0,0 +1,11 @@ +namespace Xamarin.Android.Utilities; + +interface IProcessOutputLogger +{ + IProcessOutputLogger? WrappedLogger { get; } + string? StdoutPrefix { get; set; } + string? StderrPrefix { get; set; } + + void WriteStdout (string text, bool writeLine = true); + void WriteStderr (string text, bool writeLine = true); +} diff --git a/tools/xadebug/Xamarin.Android.Utilities/ProcessRunner.cs b/tools/xadebug/Xamarin.Android.Utilities/ProcessRunner.cs new file mode 100644 index 00000000000..486a8a7a1f4 --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Utilities/ProcessRunner.cs @@ -0,0 +1,318 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Xamarin.Android.Utilities; + +class ProcessRunner2 : IDisposable +{ + public delegate void BackgroundActionCompletionHandler (ProcessRunner2 runner, ProcessStatus status); + + static readonly TimeSpan DefaultProcessTimeout = TimeSpan.FromMinutes (5); + static readonly TimeSpan DefaultOutputTimeout = TimeSpan.FromSeconds (10); + + readonly object runLock = new object (); + readonly IProcessOutputLogger? outputLogger; + readonly ILogger? logger; + readonly string command; + + bool disposed; + bool running; + List? arguments; + Task? backgroundProcess; + + public bool CreateWindow { get; set; } + public Dictionary Environment { get; } = new Dictionary (StringComparer.Ordinal); + public string? FullCommandLine { get; private set; } + public bool LeaveRunning { get; set; } + public bool LogRunInfo { get; set; } = true; + public bool LogStderr { get; set; } + public bool LogStdout { get; set; } + public bool MakeProcessGroupLeader { get; set; } + public TimeSpan ProcessTimeout { get; set; } = DefaultProcessTimeout; + public Encoding StandardOutputEncoding { get; set; } = Encoding.Default; + public Encoding StandardErrorEncoding { get; set; } = Encoding.Default; + public TimeSpan StandardOutputTimeout { get; set; } = DefaultOutputTimeout; + public TimeSpan StandardErrorTimeout { get; set; } = DefaultOutputTimeout; + public Action? CustomizeStartInfo { get; set; } + public bool UseShell { get; set; } + public ProcessWindowStyle WindowStyle { get; set; } = ProcessWindowStyle.Hidden; + public string? WorkingDirectory { get; set; } + + public ProcessRunner2 (string command, IProcessOutputLogger? outputLogger = null, ILogger? logger = null) + { + if (String.IsNullOrEmpty (command)) { + throw new ArgumentException ("must not be null or empty", nameof (command)); + } + + this.command = command; + this.outputLogger = outputLogger; + this.logger = logger; + } + + ~ProcessRunner2 () + { + Dispose (disposing: false); + } + + public void Kill (bool gracefully = true) + {} + + public void AddArguments (IEnumerable? args) + { + if (args == null) { + return; + } + + foreach (string? a in args) { + if (String.IsNullOrEmpty (a)) { + continue; + } + + AddArgument (a); + } + } + + public void AddArgument (string arg) + { + if (arguments == null) { + arguments = new List (); + } + + arguments.Add (arg); + } + + public void AddQuotedArgument (string arg) + { + AddArgument ($"\"{arg}\""); + } + + /// + /// Run process synchronously on the calling thread + /// + public ProcessStatus Run () + { + return Run (ProcessTimeout); + } + + public ProcessStatus Run (TimeSpan processTimeout) + { + MarkRunning (); + + try { + return DoRun (processTimeout); + } finally { + MarkNotRunning (); + } + } + + ProcessStatus DoRun (TimeSpan processTimeout) + { + var psi = new ProcessStartInfo (command) { + CreateNoWindow = !CreateWindow, + RedirectStandardError = LogStderr, + RedirectStandardOutput = LogStdout, + UseShellExecute = UseShell, + WindowStyle = WindowStyle, + }; + + if (arguments != null && arguments.Count > 0) { + psi.Arguments = String.Join (" ", arguments); + } + + if (Environment.Count > 0) { + foreach (var kvp in Environment) { + psi.Environment.Add (kvp.Key, kvp.Value); + } + } + + if (!String.IsNullOrEmpty (WorkingDirectory)) { + psi.WorkingDirectory = WorkingDirectory; + } + + if (psi.RedirectStandardError) { + StandardErrorEncoding = StandardErrorEncoding; + } + + if (psi.RedirectStandardOutput) { + StandardOutputEncoding = StandardOutputEncoding; + } + + if (CustomizeStartInfo != null) { + CustomizeStartInfo (psi); + } + + EnsureValidConfig (psi); + + FullCommandLine = $"{psi.FileName} {psi.Arguments}"; + + ManualResetEventSlim? stderr_done = null; + if (LogStderr) { + stderr_done = new ManualResetEventSlim (false); + } + + ManualResetEventSlim? stdout_done = null; + if (LogStdout) { + stdout_done = new ManualResetEventSlim (false); + } + + if (LogRunInfo) { + logger?.DebugLine ($"Running: {FullCommandLine}"); + } + + var process = new Process { + StartInfo = psi + }; + + try { + process.Start (); + } catch (System.ComponentModel.Win32Exception ex) { + if (logger != null) { + logger.ErrorLine ($"Process failed to start: {ex.Message}"); + logger.DebugLine (ex.ToString ()); + } + + return new ProcessStatus (); + } + + if (psi.RedirectStandardError) { + process.ErrorDataReceived += (object sender, DataReceivedEventArgs e) => { + if (e.Data != null) { + outputLogger!.WriteStderr (e.Data ?? String.Empty); + } else { + stderr_done!.Set (); + } + }; + process.BeginErrorReadLine (); + } + + if (psi.RedirectStandardOutput) { + process.OutputDataReceived += (object sender, DataReceivedEventArgs e) => { + if (e.Data != null) { + outputLogger!.WriteStdout (e.Data ?? String.Empty); + } else { + stdout_done!.Set (); + } + }; + process.BeginOutputReadLine (); + } + + int timeout = processTimeout == TimeSpan.MaxValue ? -1 : (int)processTimeout.TotalMilliseconds; + bool exited = process.WaitForExit (timeout); + if (!exited) { + logger?.ErrorLine ($"Process '{FullCommandLine}' timed out after {ProcessTimeout}"); + process.Kill (); + } + + // See: https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.process.waitforexit?view=netframework-4.7.2#System_Diagnostics_Process_WaitForExit) + if (psi.RedirectStandardError || psi.RedirectStandardOutput) { + process.WaitForExit (); + } + + if (stderr_done != null) { + stderr_done.Wait (StandardErrorTimeout); + } + + if (stdout_done != null) { + stdout_done.Wait (StandardOutputTimeout); + } + + return new ProcessStatus (process.ExitCode, exited, process.ExitCode == 0); + } + + /// + /// Run process in a separate thread. The caller is responsible for awaiting on the returned Task + /// + public Task RunAsync () + { + return Task.Run (() => Run ()); + } + + /// + /// Run process in background, calling the on completion. This is meant to be used for processes which are to run under control of our + /// process but without us actively monitoring them or awaiting their completion. By default the process will run without a timeout (the + /// property is ignored). Timeout can be changed by setting the parameter to anything other than TimeSpan.MaxValue + /// + public void RunInBackground (BackgroundActionCompletionHandler completionHandler, TimeSpan? processTimeout = null) + { + backgroundProcess = new Task ( + () => Run (processTimeout ?? TimeSpan.MaxValue), + TaskCreationOptions.LongRunning + ).ContinueWith ( + (Task task) => { + ProcessStatus status; + if (task.IsFaulted) { + status = new ProcessStatus (task.Exception!); + } else { + status = new ProcessStatus (); + } + completionHandler (this, status); + return status; + }, TaskContinuationOptions.OnlyOnFaulted + ).ContinueWith ( + (Task task) => { + completionHandler (this, task.Result); + return task.Result; + }, + TaskContinuationOptions.OnlyOnRanToCompletion + ).ContinueWith ( + (Task task) => { + var status = new ProcessStatus (); + completionHandler (this, status); + return status; + }, + TaskContinuationOptions.OnlyOnCanceled + ); + + backgroundProcess.Start (); + } + + protected virtual void Dispose (bool disposing) + { + if (disposed) { + return; + } + + if (disposing) { + // TODO: dispose managed state (managed objects) + } + + // TODO: free unmanaged resources (unmanaged objects) and override finalizer + // TODO: set large fields to null + disposed = true; + } + + public void Dispose () + { + Dispose (disposing: true); + GC.SuppressFinalize (this); + } + + void MarkRunning () + { + lock (runLock) { + if (running) { + throw new InvalidOperationException ("Process already running"); + } + + running = true; + } + } + + void MarkNotRunning () + { + lock (runLock) { + running = false; + } + } + + void EnsureValidConfig (ProcessStartInfo psi) + { + if ((psi.RedirectStandardOutput || psi.RedirectStandardError) && outputLogger == null) { + throw new InvalidOperationException ("Process output logger must be set in order to capture standard output streams"); + } + } +} diff --git a/tools/xadebug/Xamarin.Android.Utilities/ProcessStatus.cs b/tools/xadebug/Xamarin.Android.Utilities/ProcessStatus.cs new file mode 100644 index 00000000000..9ef291305a9 --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Utilities/ProcessStatus.cs @@ -0,0 +1,26 @@ +using System; + +namespace Xamarin.Android.Utilities; + +class ProcessStatus +{ + public int ExitCode { get; } = -1; + public bool Exited { get; } = false; + public bool Success { get; } = false; + public Exception? Exception { get; } + + public ProcessStatus () + {} + + public ProcessStatus (Exception ex) + { + Exception = ex; + } + + public ProcessStatus (int exitCode, bool exited, bool success) + { + ExitCode = exitCode; + Exited = exited; + Success = success; + } +} diff --git a/tools/xadebug/Xamarin.Android.Utilities/ToolRunner.cs b/tools/xadebug/Xamarin.Android.Utilities/ToolRunner.cs new file mode 100644 index 00000000000..90561688101 --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Utilities/ToolRunner.cs @@ -0,0 +1,126 @@ +using System; + +namespace Xamarin.Android.Utilities; + +abstract class ToolRunner2 : IDisposable +{ + sealed class ConsoleProcessLogger : IProcessOutputLogger + { + bool echoStdout; + bool echoStderr; + IProcessOutputLogger wrappedLogger; + ILogger logger; + + public IProcessOutputLogger? WrappedLogger => wrappedLogger; + public string? StdoutPrefix { get; set; } + public string? StderrPrefix { get; set; } = "stderr> "; + + public ConsoleProcessLogger (ILogger logger, IProcessOutputLogger wrappedLogger, bool echoStdout, bool echoStderr) + { + this.logger = logger; + this.wrappedLogger = wrappedLogger; + this.echoStdout = echoStdout; + this.echoStderr = echoStderr; + } + + public void WriteStderr (string text, bool writeNewline) + { + if (echoStderr) { + string message = $"{GetPrefix (StderrPrefix)}{text}"; + if (writeNewline) { + logger.ErrorLine (message); + } else { + logger.Error (message); + } + } + + wrappedLogger.WriteStderr (text, writeNewline); + } + + public void WriteStdout (string text, bool writeNewline) + { + if (echoStdout) { + string message = $"{GetPrefix (StdoutPrefix)}{text}"; + if (writeNewline) { + logger.MessageLine (message); + } else { + logger.Message (message); + } + } + + wrappedLogger.WriteStdout (text, writeNewline); + } + + string GetPrefix (string? prefix) => prefix ?? String.Empty; + } + + static readonly TimeSpan DefaultProcessTimeout = TimeSpan.FromMinutes (15); + + bool disposed; + ILogger logger; + IProcessOutputLogger processOutputLogger; + + protected ILogger Log => logger; + protected IProcessOutputLogger OutputLogger => processOutputLogger; + + public string ToolPath { get; } + public bool EchoCmdAndArguments { get; set; } = true; + public bool EchoStandardError { get; set; } = true; + public bool EchoStandardOutput { get; set; } + public TimeSpan ProcessTimeout { get; set; } = DefaultProcessTimeout; + + protected ToolRunner2 (string toolPath, ILogger logger, IProcessOutputLogger processOutputLogger) + { + if (String.IsNullOrEmpty (toolPath)) { + throw new ArgumentException ("must not be null or empty", nameof (toolPath)); + } + + this.logger = logger; + this.processOutputLogger = processOutputLogger; + + ToolPath = toolPath; + } + + ~ToolRunner2 () + { + Dispose (disposing: false); + } + + protected ProcessRunner2 InitProcessRunner (object? state, params string?[]? initialParams) + { + var consoleLogger = new ConsoleProcessLogger (logger, processOutputLogger, EchoStandardOutput, EchoStandardError); + return CreateProcessRunner (consoleLogger, state, initialParams); + } + + protected virtual ProcessRunner2 CreateProcessRunner (IProcessOutputLogger consoleProcessLogger, object? state, params string?[]? initialParams) + { + var runner = new ProcessRunner2 (ToolPath, consoleProcessLogger, logger) { + ProcessTimeout = ProcessTimeout, + LogRunInfo = EchoCmdAndArguments, + LogStdout = true, + LogStderr = true, + }; + + runner.AddArguments (initialParams); + return runner; + } + + protected virtual void Dispose (bool disposing) + { + if (disposed) { + return; + } + + if (disposing) { + // TODO: dispose managed state (managed objects) + } + + disposed = true; + } + + public void Dispose () + { + Dispose (disposing: true); + GC.SuppressFinalize (this); + } +} diff --git a/tools/xadebug/Xamarin.Android.Utilities/XamarinLoggingHelper.cs b/tools/xadebug/Xamarin.Android.Utilities/XamarinLoggingHelper.cs new file mode 100644 index 00000000000..a17aa6d8018 --- /dev/null +++ b/tools/xadebug/Xamarin.Android.Utilities/XamarinLoggingHelper.cs @@ -0,0 +1,234 @@ +using System; +using System.IO; + +namespace Xamarin.Android.Utilities; + +enum LogLevel +{ + Error, + Warning, + Info, + Message, + Debug +} + +class XamarinLoggingHelper : ILogger, IProcessOutputLogger +{ + static readonly object consoleLock = new object (); + string? logFilePath = null; + string? logFileDir = null; + + public const ConsoleColor ErrorColor = ConsoleColor.Red; + public const ConsoleColor DebugColor = ConsoleColor.DarkGray; + public const ConsoleColor InfoColor = ConsoleColor.Green; + public const ConsoleColor MessageColor = ConsoleColor.Gray; + public const ConsoleColor WarningColor = ConsoleColor.Yellow; + public const ConsoleColor StatusLabel = ConsoleColor.Cyan; + public const ConsoleColor StatusText = ConsoleColor.White; + + public bool Verbose { get; set; } + public string? LogFilePath { + get => logFilePath; + set { + if (!String.IsNullOrEmpty (value)) { + string? dir = Path.GetDirectoryName (value); + if (!String.IsNullOrEmpty (dir)) { + Directory.CreateDirectory (dir); + } + } + + logFilePath = value; + logFileDir = Path.GetDirectoryName (value); + } + } + + public IProcessOutputLogger? WrappedLogger => null; + + public string? StdoutPrefix { + get => String.Empty; + set { + // no op + } + } + + public string? StderrPrefix { + get => "stderr> "; + set { + // no op + } + } + + public void Message (string? message) + { + Log (LogLevel.Message, message); + } + + public void MessageLine (string? message = null) + { + Message ($"{message ?? String.Empty}{Environment.NewLine}"); + } + + public void Warning (string? message) + { + Log (LogLevel.Warning, message); + } + + public void WarningLine (string? message = null) + { + Warning ($"{message ?? String.Empty}{Environment.NewLine}"); + } + + public void Error (string? message) + { + Log (LogLevel.Error, message); + } + + public void ErrorLine (string? message = null) + { + Error ($"{message ?? String.Empty}{Environment.NewLine}"); + } + + public void Info (string? message) + { + Log (LogLevel.Info, message); + } + + public void InfoLine (string? message = null) + { + Info ($"{message ?? String.Empty}{Environment.NewLine}"); + } + + public void Debug (string? message) + { + Log (LogLevel.Debug, message); + } + + public void DebugLine (string? message = null) + { + Debug ($"{message ?? String.Empty}{Environment.NewLine}"); + } + + public void Status (string label, string text) + { + Log (LogLevel.Info, $"{label}: ", StatusLabel); + Log (LogLevel.Info, $"{text}", StatusText); + } + + public void StatusLine (string label, string text) + { + Status (label, text); + Log (LogLevel.Info, Environment.NewLine); + } + + public void Log (LogLevel level, string? message) + { + if (!Verbose && level == LogLevel.Debug) { + LogToFile (message); + return; + } + + Log (level, message, ForegroundColor (level)); + } + + public void LogLine (LogLevel level, string? message, ConsoleColor color) + { + Log (level, message, color); + Log (level, Environment.NewLine, color); + } + + public void Log (LogLevel level, string? message, ConsoleColor color) + { + LogToFile (message); + + if (!Verbose && level == LogLevel.Debug) { + return; + } + + TextWriter writer = level == LogLevel.Error ? Console.Error : Console.Out; + message = message ?? String.Empty; + + ConsoleColor fg = ConsoleColor.Gray; + try { + lock (consoleLock) { + fg = Console.ForegroundColor; + Console.ForegroundColor = color; + } + + writer.Write (message); + } finally { + Console.ForegroundColor = fg; + } + } + + void LogToFile (string? message) + { + if (String.IsNullOrEmpty (LogFilePath)) { + return; + } + + if (!String.IsNullOrEmpty (logFileDir) && !Directory.Exists (logFileDir)) { + Directory.CreateDirectory (logFileDir); + } + + File.AppendAllText (LogFilePath, message); + } + + ConsoleColor ForegroundColor (LogLevel level) => level switch { + LogLevel.Error => ErrorColor, + LogLevel.Warning => WarningColor, + LogLevel.Info => InfoColor, + LogLevel.Debug => DebugColor, + LogLevel.Message => MessageColor, + _ => MessageColor, + }; + +#region MSBuild compatibility methods + public void LogDebugMessage (string message, params object[] messageArgs) + { + if (messageArgs == null || messageArgs.Length == 0) { + DebugLine (message); + } else { + DebugLine (String.Format (message, messageArgs)); + } + } + + public void LogError (string message, params object[] messageArgs) + { + if (messageArgs == null || messageArgs.Length == 0) { + ErrorLine (message); + } else { + ErrorLine (String.Format (message, messageArgs)); + } + } + + public void LogMessage (string message, params object[] messageArgs) + { + if (messageArgs == null || messageArgs.Length == 0) { + MessageLine (message); + } else { + MessageLine (String.Format (message, messageArgs)); + } + } + + public void LogWarning (string message, params object[] messageArgs) + { + if (messageArgs == null || messageArgs.Length == 0) { + WarningLine (message); + } else { + WarningLine (String.Format (message, messageArgs)); + } + } + + public void WriteStdout (string text, bool writeLine = true) + { + LogToFile ($"{StdoutPrefix}{text}{GetNewline (writeLine)}"); + } + + public void WriteStderr (string text, bool writeLine = true) + { + LogToFile ($"{StderrPrefix}{text}{GetNewline (writeLine)}"); + } + + string GetNewline (bool yes) => yes ? Environment.NewLine : String.Empty; + #endregion +} diff --git a/tools/xadebug/xadebug.csproj b/tools/xadebug/xadebug.csproj new file mode 100644 index 00000000000..c2784f5a620 --- /dev/null +++ b/tools/xadebug/xadebug.csproj @@ -0,0 +1,60 @@ + + + + + Exe + net7.0 + False + enable + NO_MSBUILD + true + + Marek Habersack + Microsoft Corporation + 2022 © Microsoft Corporation + true + xadebug + nupkg + A tool to debug native code of Xamarin.Android applications + README.md + LICENSE + https://github.com/xamarin/xamarin-android + Major + + + + + + + + + + + Crc64.cs + + + Crc64Helper.cs + + + Crc64.Table.cs + + + + + + xa_start_lldb_server.sh + + + + + + + + + + + + + + +