diff --git a/tests/xharness/Harness.cs b/tests/xharness/Harness.cs index 518eb63083c1..94621fd64357 100644 --- a/tests/xharness/Harness.cs +++ b/tests/xharness/Harness.cs @@ -194,6 +194,29 @@ string GetVariable (string variable, string @default = null) return result; } +#nullable enable + string? spawnerPath; + public string SpawnerPath { + get { + if (spawnerPath is null) + spawnerPath = Path.GetFullPath (Path.Combine (RootDirectory, "..", "tools", "spawner", "spawner")); + return spawnerPath; + } + } + + public void UseSpawner (ProcessStartInfo processStartInfo, IList arguments) + { + if (!string.IsNullOrEmpty (processStartInfo.Arguments)) + throw new InvalidOperationException ($"ProcessStartInfo.Arguments must be empty when using UseSpawner."); + + var originalFileName = processStartInfo.FileName; + processStartInfo.FileName = SpawnerPath; + processStartInfo.ArgumentList.Add (originalFileName); + foreach (var args in arguments) + processStartInfo.ArgumentList.Add (args); + } +#nullable disable + public List TestProjects { get; } = new (); readonly bool useSystemXamarinIOSMac; // if the system XI/XM should be used, or the locally build XI/XM. diff --git a/tests/xharness/IHarness.cs b/tests/xharness/IHarness.cs index 7a3c0c1ebb4c..673ff36e1564 100644 --- a/tests/xharness/IHarness.cs +++ b/tests/xharness/IHarness.cs @@ -46,6 +46,8 @@ public interface IHarness { bool InCI { get; } bool UseTcpTunnel { get; } string VSDropsUri { get; } + string SpawnerPath { get; } + void UseSpawner (System.Diagnostics.ProcessStartInfo startInfo, IList arguments); #endregion diff --git a/tests/xharness/Jenkins/TestTasks/MacExecuteTask.cs b/tests/xharness/Jenkins/TestTasks/MacExecuteTask.cs index 736b5cb079cf..f41d0b123535 100644 --- a/tests/xharness/Jenkins/TestTasks/MacExecuteTask.cs +++ b/tests/xharness/Jenkins/TestTasks/MacExecuteTask.cs @@ -85,7 +85,10 @@ public override async Task RunTestAsync () proc.StartInfo.EnvironmentVariables ["DISABLE_SYSTEM_PERMISSION_TESTS"] = "1"; proc.StartInfo.EnvironmentVariables ["MONO_DEBUG"] = "no-gdb-backtrace"; proc.StartInfo.EnvironmentVariables.Remove ("DYLD_FALLBACK_LIBRARY_PATH"); // VSMac might set this, and the test may end up crashing - proc.StartInfo.Arguments = StringUtils.FormatArguments (arguments); + + // Use the spawner to launch the app, to avoid issues with macOS getting confused who's the responsible process + Harness.UseSpawner (proc.StartInfo, arguments); + Jenkins.MainLog.WriteLine ("Executing {0} ({1})", TestName, Mode); var log = Logs.Create ($"execute-{Platform}-{Timestamp}.txt", LogType.ExecutionLog.ToString ()); ICrashSnapshotReporter snapshot = null; diff --git a/tools/Makefile b/tools/Makefile index 16687fc8fa91..6e8804845ed4 100644 --- a/tools/Makefile +++ b/tools/Makefile @@ -25,3 +25,4 @@ endif SUBDIRS+=mlaunch SUBDIRS += dotnet-linker +SUBDIRS += spawner diff --git a/tools/devops/automation/templates/tests/run-tests.yml b/tools/devops/automation/templates/tests/run-tests.yml index 0e925c2505b3..ec0ccb208f79 100644 --- a/tools/devops/automation/templates/tests/run-tests.yml +++ b/tools/devops/automation/templates/tests/run-tests.yml @@ -79,8 +79,9 @@ steps: make -C src build/generator-frameworks.g.cs make -C src build/ios/Constants.cs make -C msbuild Versions.g.cs + make -C tools/spawner workingDirectory: $(System.DefaultWorkingDirectory)/$(BUILD_REPOSITORY_TITLE) - displayName: Generate constants files + displayName: "Generate / compile dependencies" timeoutInMinutes: 15 - pwsh: >- diff --git a/tools/spawner/.gitignore b/tools/spawner/.gitignore new file mode 100644 index 000000000000..35bc622fa398 --- /dev/null +++ b/tools/spawner/.gitignore @@ -0,0 +1,3 @@ +.libs +spawner + diff --git a/tools/spawner/Makefile b/tools/spawner/Makefile new file mode 100644 index 000000000000..d26d76c9d537 --- /dev/null +++ b/tools/spawner/Makefile @@ -0,0 +1,15 @@ +TOP=../.. +include $(TOP)/Make.config +include $(TOP)/mk/rules.mk + +.libs/osx-arm64/spawner: .libs/osx-arm64/spawner.o + $(Q_CCLD) $(CLANG) -arch $(DOTNET_osx-arm64_ARCHITECTURES) $< -o$@ -isysroot $(macos_SDK) + +.libs/osx-x64/spawner: .libs/osx-x64/spawner.o + $(Q_CCLD) $(CLANG) -arch $(DOTNET_osx-x64_ARCHITECTURES) $< -o$@ -isysroot $(macos_SDK) + +spawner: .libs/osx-arm64/spawner .libs/osx-x64/spawner + $(Q_LIPO) $(LIPO) $^ -create -output $@ + $(Q) chmod +x $@ + +all-local:: spawner diff --git a/tools/spawner/README.md b/tools/spawner/README.md new file mode 100644 index 000000000000..fc0d067dd58d --- /dev/null +++ b/tools/spawner/README.md @@ -0,0 +1,69 @@ +SPAWNER +======= + +This is a very simple tool, which executes another process, disclaiming any +responsibility for it. + +This is important when executing tests apps, because when macOS sees that an +app uses API that needs specific entries in the Info.plist (such as the +`NSAppleMusicUsageDescription`), the responsible process is where macOS looks +for said key. + +Example crash report: + +``` +Process: introspection [85822] +Path: /Users/USER/*/introspection.app/Contents/MacOS/introspection +Identifier: com.xamarin.introspection +Version: 1.0 (1.0) +Code Type: ARM-64 (Native) +Parent Process: dotnet [81129] +Responsible: Electron [68966] +User ID: 501 + +Date/Time: 2025-11-13 17:33:10.8123 +0100 +OS Version: macOS 15.7.2 (24G325) +Report Version: 12 +Anonymous UUID: F22C0F06-0F16-E475-C0CB-264A0FF4F6A3 + + +Time Awake Since Boot: 27000 seconds + +System Integrity Protection: enabled + +Crashed Thread: 16 Dispatch queue: com.apple.root.default-qos + +Exception Type: EXC_CRASH (SIGKILL) +Exception Codes: 0x0000000000000000, 0x0000000000000000 + +Termination Reason: Namespace TCC, Code 0 +This app has crashed because it attempted to access privacy-sensitive data without a usage description. The app's Info.plist must contain an NSAppleMusicUsageDescription key with a string value explaining to the user how the app uses this data. +``` + +The app crashed because macOS says it needs the `NSAppleMusicUsageDescription` entry in its `Info.plist` file. + +This is confusing, because introspection has an `NSAppleMusicUsageDescription` entry in its `Info.plist` file. + +Here's what happens: + +Note that there's a "Responsible [Process]" (Electron 68966) line, which is not the same as "Process" (introspection 85822), and this is the crux of the matter. + +In this particular case: + +* I opened the xharness project in VSCode. +* I launched the xharness project in the debugger, and then ran introspection for Mac Catalyst. +* The responsible process ended up being VS Code (aka Electron, with pid 85822), and that's where macOS ended up looking for the `NSAppleMusicUsageDescription` key. + +The fix is to launch `introspection` (and any other test app on macOS) using +this `spawner` tool, which disclaims reponsibility for anything it launches, +thus letting `introspection` be a grown up process and fully responsible for +itself. + +Usage is simple: just pass the executable + any arguments to `spawner`. + +References: + +* https://gitlab.com/gnachman/iterm2/-/issues/10360 +* https://github.com/llvm/llvm-project/commit/041c7b84a4b925476d1e21ed302786033bb6035f#diff-a38ae411ccf0c85f3d7c0c45d8e1ad035030d5171d59e478b86a094941d3209dR16-R17 +* https://lldb.llvm.org/cpp_reference/PosixSpawnResponsible_8h_source.html +* https://steipete.me/posts/2025/applescript-cli-macos-complete-guide diff --git a/tools/spawner/spawner.c b/tools/spawner/spawner.c new file mode 100644 index 000000000000..7ba4f6fc5f00 --- /dev/null +++ b/tools/spawner/spawner.c @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#include +#include +#include +#include +#include + +errno_t responsibility_spawnattrs_setdisclaim (posix_spawnattr_t *attrs, bool disclaim); + +int main (int argc, char** argv, char** envp) +{ + if (argc < 2) { + fprintf (stderr, + "spawner: launch a subprocess, disclaiming all responsibilities with regards to TCC:\n" + "usage: spawner [arguments]\n"); + return 1; + } + + int rv; + // Behave as exec + short flags = POSIX_SPAWN_SETEXEC; + posix_spawnattr_t spawnattr; + sigset_t sigset; + + rv = posix_spawnattr_init (&spawnattr); + if (rv) { + fprintf (stderr, "Failed to execute 'posix_spawnattr_init': %i (%s)\n", rv, strerror (rv)); + return 1; + } + + // Reset the signal mask + sigemptyset (&sigset); + rv = posix_spawnattr_setsigmask (&spawnattr, &sigset); + if (rv) { + fprintf (stderr, "Failed to execute 'posix_spawnattr_setsigmask': %i (%s)\n", rv, strerror (rv)); + return 1; + } + flags |= POSIX_SPAWN_SETSIGMASK; + + // Reset all signals to their default handlers + sigfillset (&sigset); + rv = posix_spawnattr_setsigdefault (&spawnattr, &sigset); + if (rv) { + fprintf (stderr, "Failed to execute 'posix_spawnattr_setsigdefault': %i (%s)\n", rv, strerror (rv)); + return 1; + } + flags |= POSIX_SPAWN_SETSIGDEF; + + rv = posix_spawnattr_setflags (&spawnattr, flags); + if (rv) { + fprintf (stderr, "Failed to execute 'posix_spawnattr_setflags': %i (%s)\n", rv, strerror (rv)); + return 1; + } + + rv = responsibility_spawnattrs_setdisclaim (&spawnattr, 1); + if (rv) { + fprintf (stderr, "Failed to execute 'responsibility_spawnattrs_setdisclaim': %i (%s)\n", rv, strerror (rv)); + return 1; + } + + pid_t pid = 0; + rv = posix_spawnp (&pid, argv [1], NULL, &spawnattr, argv + 1, envp); + posix_spawnattr_destroy (&spawnattr); + + // posix_spawnp shouldn't return (because we set the POSIX_SPAWN_SETEXEC flag) + // so if it did, something went wrong + fprintf (stderr, "Failed to execute '%s': %i (%s)\n", argv [1], rv, strerror (rv)); + return 1; +}