Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion tests/common/shared-dotnet.mk
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ include $(TOP)/mk/colors.mk

unexport MSBUILD_EXE_PATH

SPAWNER?=$(TOP)/tools/spawner/spawner

BINLOG_TIMESTAMP:=$(shell date +%Y-%m-%d-%H%M%S)

ifeq ($(TESTNAME),)
Expand Down Expand Up @@ -202,7 +204,7 @@ delete-saved-state:
run-bare: export RUNTIMEIDENTIFIER=
run-bare: export RUNTIMEIDENTIFIERS=
run-bare: delete-saved-state
$(Q) $(EXECUTABLE) --autostart --autoexit $(RUN_ARGUMENTS)
$(Q) $(SPAWNER) $(EXECUTABLE) --autostart --autoexit $(RUN_ARGUMENTS)
Comment thread
rolfbjarne marked this conversation as resolved.
$(Q) $(MAKE) delete-saved-state

# Get the list of applicable simulators, and pick the first in the list.
Expand Down
7 changes: 4 additions & 3 deletions tests/monotouch-test/AudioToolbox/AudioConverterTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,11 @@ public void ConvertWithPacketDependencies (AudioFormatType targetType)
TestRuntime.AssertXcodeVersion (26, 0);

var sourcePath = Path.Combine (NSBundle.MainBundle.ResourcePath, "Hand.wav");
var paths = NSSearchPath.GetDirectories (NSSearchPathDirectory.DocumentDirectory, NSSearchPathDomain.User);

var output1 = Path.Combine (paths [0], "output1.caf");
Convert (sourcePath, output1, targetType, withPacketDependencies: true);
DoWithTemporaryDirectory ((temporaryDirectory) => {
var output1 = Path.Combine (temporaryDirectory, "output1.caf");
Convert (sourcePath, output1, targetType, withPacketDependencies: true);
});
}

void Convert (string sourceFilePath, string destinationFilePath, AudioFormatType outputFormatType, int? sampleRate = null, AudioConverterOptions? options = null, bool withPacketDependencies = false)
Expand Down
6 changes: 4 additions & 2 deletions tests/monotouch-test/Foundation/FileManagerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,10 @@ public void GetSkipBackupAttribute ()
{
Assert.That (NSFileManager.GetSkipBackupAttribute (NSBundle.MainBundle.ExecutableUrl.ToString ()), Is.False, "MainBundle");

var paths = NSSearchPath.GetDirectories (NSSearchPathDirectory.DocumentDirectory, NSSearchPathDomain.User);
var filename = Path.Combine (paths [0], $"DoNotBackupMe-NSFileManager-{Process.GetCurrentProcess ().Id}");
// Use the temporary directory instead of the Documents directory: writing to the Documents
// directory can trigger a TCC ("Files and Folders") permission prompt on macOS, which hangs
// forever in CI (there's no user around to answer the prompt).
var filename = Path.Combine (NSFileManager.TemporaryDirectory, $"DoNotBackupMe-NSFileManager-{Process.GetCurrentProcess ().Id}");
try {
File.WriteAllText (filename, "not worth a bit");

Expand Down
6 changes: 4 additions & 2 deletions tests/monotouch-test/Foundation/UrlTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ public void IsExcludedFromBackupKey ()
Assert.That (value, Is.TypeOf (typeof (NSNumber)), "NSNumber");
Assert.That ((int) (value as NSNumber), Is.EqualTo (0), "0");

var paths = NSSearchPath.GetDirectories (NSSearchPathDirectory.DocumentDirectory, NSSearchPathDomain.User);
var filename = Path.Combine (paths [0], $"DoNotBackupMe-NSUrl-{Process.GetCurrentProcess ().Id}");
// Use the temporary directory instead of the Documents directory: writing to the Documents
// directory can trigger a TCC ("Files and Folders") permission prompt on macOS, which hangs
// forever in CI (there's no user around to answer the prompt).
var filename = Path.Combine (NSFileManager.TemporaryDirectory, $"DoNotBackupMe-NSUrl-{Process.GetCurrentProcess ().Id}");
try {
File.WriteAllText (filename, "not worth a bit");
using (NSUrl url = NSUrl.FromFilename (filename)) {
Expand Down
28 changes: 27 additions & 1 deletion tests/xharness/Harness.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,31 @@ bool IsVariableSet (string variable)
return result;
}

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<string> arguments)
{
if (!string.IsNullOrEmpty (processStartInfo.Arguments))
throw new InvalidOperationException ($"ProcessStartInfo.Arguments must be empty when using UseSpawner.");
if (processStartInfo.ArgumentList.Count > 0)
throw new InvalidOperationException ($"ProcessStartInfo.ArgumentList must be empty when using UseSpawner.");
if (!File.Exists (SpawnerPath))
throw new FileNotFoundException ($"The spawner executable was not found. Did you build it? (make -C tools/spawner)", SpawnerPath);

var originalFileName = processStartInfo.FileName;
processStartInfo.FileName = SpawnerPath;
processStartInfo.ArgumentList.Add (originalFileName);
foreach (var args in arguments)
processStartInfo.ArgumentList.Add (args);
}

public List<TestProject> TestProjects { get; } = new ();

public bool INCLUDE_IOS { get; }
Expand Down Expand Up @@ -290,7 +315,8 @@ public bool GetIncludeSystemPermissionTests (TestPlatform platform, bool device)
switch (platform) {
case TestPlatform.iOS:
case TestPlatform.Mac:
// On macOS we can't edit the TCC database easily
case TestPlatform.MacCatalyst:
// On macOS (and Mac Catalyst, which also runs natively, not in a simulator) we can't edit the TCC database easily
// (it requires adding the mac has to be using MDM: https://carlashley.com/2018/09/28/tcc-round-up/)
// So by default ignore any tests that would pop up permission dialogs in CI.
return !InCI;
Expand Down
7 changes: 5 additions & 2 deletions tests/xharness/Jenkins/TestTasks/MacExecuteTask.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,11 @@ 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);
Jenkins.MainLog.WriteLine ("Executing {0} ({1})", TestName, Mode);

// 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} - {2})", TestName, Mode, Variation);
var log = Logs.Create ($"execute-{Platform}-{Timestamp}.txt", LogType.ExecutionLog.ToString ());
ICrashSnapshotReporter? snapshot = null;
if (!Jenkins.Harness.DryRun) {
Expand Down
2 changes: 1 addition & 1 deletion tests/xharness/Jenkins/TestTasks/RunTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ public async Task ExecuteProcessAsync (ILog log, string filename, List<string> a
envManager.SetEnvironmentVariables (proc);
foreach (DictionaryEntry de in proc.StartInfo.EnvironmentVariables)
log.WriteLine ($"export {de.Key}={de.Value}");
mainLog.WriteLine ("Executing {0} ({1})", testTask.TestName, testTask.Mode);
mainLog.WriteLine ("Executing {0} ({1} - {2})", testTask.TestName, testTask.Mode, testTask.Variation);
if (!dryRun) {
testTask.ExecutionResult = TestExecutingResult.Running;
var result = await ProcessManager.RunAsync (proc, log, Timeout);
Expand Down
4 changes: 4 additions & 0 deletions tools/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,8 @@ SUBDIRS+=mlaunch

SUBDIRS += dotnet-linker

ifndef NO_XCODE
SUBDIRS += spawner
endif

SUBDIRS += assembly-preparer
5 changes: 3 additions & 2 deletions tools/devops/automation/templates/tests/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ steps:
- bash: |
set -ex
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: >-
Expand Down Expand Up @@ -127,7 +128,7 @@ steps:
workingDirectory: $(System.DefaultWorkingDirectory)/$(BUILD_REPOSITORY_TITLE)
displayName: 'Run tests'
name: runTests # not to be confused with the displayName, this is used to later use the name of the step to access the output variables from an other job
timeoutInMinutes: 840
timeoutInMinutes: 165
${{ if not(parameters.isPR) }}:
retryCountOnTaskFailure: ${{ parameters.retryCount }}

Expand Down
3 changes: 3 additions & 0 deletions tools/spawner/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.libs
spawner

33 changes: 33 additions & 0 deletions tools/spawner/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
TOP=../..
include $(TOP)/Make.config
include $(TOP)/mk/rules.mk

# The spawner is a host tool: it always runs on the macOS build machine, and is
# needed regardless of which platforms are enabled. Don't use the generic
# per-RID rules from mk/rules.mk, because those are only defined for the
# platforms that are enabled - so when macOS is disabled (which is the case for
# most of the test jobs in CI) the .libs/osx-*/spawner.o rules don't exist, and
# the build fails with "No rule to make target '.libs/osx-arm64/spawner.o'".
# For the same reason we compute the macOS SDK path directly instead of relying
# on $(macos_SDK).

SPAWNER_ARCHS=arm64 x86_64
SPAWNER_SDK=$(shell $(XCODE_DEVELOPER_ROOT)/usr/bin/xcodebuild -version -sdk macosx Path 2>/dev/null)

define SpawnerTemplate
.libs/$(1):
$$(Q) mkdir -p $$@

.libs/$(1)/spawner.o: spawner.c | .libs/$(1)
$$(Q_CC) $$(CLANG) -arch $(1) -isysroot $$(SPAWNER_SDK) -c $$< -o $$@

.libs/$(1)/spawner: .libs/$(1)/spawner.o
$$(Q_CCLD) $$(CLANG) -arch $(1) -isysroot $$(SPAWNER_SDK) $$< -o $$@
endef
$(foreach arch,$(SPAWNER_ARCHS),$(eval $(call SpawnerTemplate,$(arch))))

spawner: $(foreach arch,$(SPAWNER_ARCHS),.libs/$(arch)/spawner)
$(Q_LIPO) $(LIPO) $^ -create -output $@
$(Q) chmod +x $@

all-local:: spawner
69 changes: 69 additions & 0 deletions tools/spawner/README.md
Original file line number Diff line number Diff line change
@@ -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 68966), 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 responsibility 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
72 changes: 72 additions & 0 deletions tools/spawner/spawner.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

#include <errno.h>
#include <signal.h>
#include <spawn.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>

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 <command> [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;
}
Comment thread
rolfbjarne marked this conversation as resolved.

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;
}
Loading