"fork" is no-no but why?

Why is "fork" prohibited in sandboxed apps?

Replies

Why is fork prohibited in sandboxed apps?

It isn’t.

App Sandbox is a macOS technology, and macOS apps are allowed to spawn child processes regardless of whether they’re sandboxed or not. Although I recommend that you use posix_spawn, or a wrapper like NSTask, because fork-without-exec is not safe on any Apple platform [1].

Hmmm, perhaps you’re talking about iOS. In that case:

  • iOS apps are sandboxed, but it’s not App Sandbox, which is a Mac-specific thing.

  • You are correct that iOS apps are not allowed to spawn child processes.

  • I can’t explain why that is. See tip 3 in Quinn’s Top Ten DevForums Tips.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] See my explanation here.

It's for macOS. In my experience the child process crashes if I enable sandbox in the main app. Minimal test:

Test.m

#include "Test.h"
#import <Cocoa/Cocoa.h>
#include <spawn.h>

void launchWithFork(void) {
    pid_t pid = fork();
    if (pid == -1) {
        printf("error %d\n", pid);
    } else if (pid == 0) {
        execv("/System/Applications/Preview.app/Contents/MacOS/Preview", nil);
        exit(0);
    } else {
        // parent
    }
}
void launchWithSpawn(void) {
    pid_t pid = 0;
    int result = posix_spawn(&pid, "/System/Applications/Preview.app/Contents/MacOS/Preview", nil, nil, nil, nil);
    printf("result: %d\n", result);
}
void launchWithTask(void) {
    NSURL* url = [NSURL fileURLWithPath:@"/System/Applications/Preview.app/Contents/MacOS/Preview"];
    NSError* error = nil;
    NSTask* task = [NSTask launchedTaskWithExecutableURL:url arguments:@[] error:&error terminationHandler:nil];
    NSLog(@"task %@, error: %@", task, error);
}
void launchWithWorkspace(void) {
    NSURL* url = [NSURL fileURLWithPath:@"/System/Applications/Preview.app/Contents/MacOS/Preview"];
    NSError* error = nil;
    NSApplication* app = [NSWorkspace.sharedWorkspace launchApplicationAtURL:url options:0 configuration:@{} error:&error];
    NSLog(@"task %@, error: %@", app, error);
}

Test.h

void launchWithFork(void);
void launchWithSpawn(void);
void launchWithTask(void);
void launchWithWorkspace(void);

Bridging header

#include "Test.h"

main.swift

launchWithFork()
//launchWithSpawn()
//launchWithTask()
//launchWithWorkspace()
RunLoop.current.run(until: .distantFuture)

Without app sandbox enabled all launching methods work. With app sandbox enabled only the last method works. (I'm launching Preview.app for a test, in the actual app it will launch a different thing).

The crash in the launched app looks like this:

Process:               Preview [22445]
Path:                  /System/Applications/Preview.app/Contents/MacOS/Preview
...
System Integrity Protection: enabled

Crashed Thread:        0  Dispatch queue: com.apple.main-thread

Exception Type:        EXC_BREAKPOINT (SIGTRAP)
Exception Codes:       0x0000000000000001, 0x00000001a554d1c0

Termination Reason:    Namespace SIGNAL, Code 5 Trace/BPT trap: 5
Terminating Process:   exc handler [22445]

Application Specific Signatures:
SYSCALL_SET_PROFILE

Thread 0 Crashed::  Dispatch queue: com.apple.main-thread
0   libsystem_secinit.dylib       	       0x1a554d1c0 _libsecinit_appsandbox.cold.5 + 92
1   libsystem_secinit.dylib       	       0x1a554c3d0 _libsecinit_appsandbox + 1688
2   libsystem_trace.dylib         	       0x19994d81c _os_activity_initiate_impl + 64
3   libsystem_secinit.dylib       	       0x1a554bce4 _libsecinit_initializer + 80
4   libSystem.B.dylib             	       0x1a5562654 libSystem_initializer + 272
5   dyld                          	       0x1998901d8 invocation function for block in dyld4::Loader::findAndRunAllInitializers(dyld4::RuntimeState&) const::$_0::operator()() const + 168
6   dyld                          	       0x1998d1e94 invocation function for block in dyld3::MachOAnalyzer::forEachInitializer(Diagnostics&, dyld3::MachOAnalyzer::VMAddrConverter const&, void (unsigned int) block_pointer, void const*) const + 340
7   dyld                          	       0x1998c51a4 invocation function for block in dyld3::MachOFile::forEachSection(void (dyld3::MachOFile::SectionInfo const&, bool, bool&) block_pointer) const + 528
8   dyld                          	       0x1998702d8 dyld3::MachOFile::forEachLoadCommand(Diagnostics&, void (load_command const*, bool&) block_pointer) const + 296
9   dyld                          	       0x1998c41cc dyld3::MachOFile::forEachSection(void (dyld3::MachOFile::SectionInfo const&, bool, bool&) block_pointer) const + 192
10  dyld                          	       0x1998d1958 dyld3::MachOAnalyzer::forEachInitializer(Diagnostics&, dyld3::MachOAnalyzer::VMAddrConverter const&, void (unsigned int) block_pointer, void const*) const + 516
11  dyld                          	       0x19988c85c dyld4::Loader::findAndRunAllInitializers(dyld4::RuntimeState&) const + 448
12  dyld                          	       0x199895f6c dyld4::PrebuiltLoader::runInitializers(dyld4::RuntimeState&) const + 44
13  dyld                          	       0x1998b07fc dyld4::APIs::runAllInitializersForMain() + 76
14  dyld                          	       0x1998752d0 dyld4::prepare(dyld4::APIs&, dyld3::MachOAnalyzer const*) + 3480
15  dyld                          	       0x199873e18 start + 1964
...

Minimal test

That’s not because fork is disallowed but because the child process is trying to change its sandbox.

When you create a child process with fork it inherits the static sandbox of the parent process. If you then exec* a different sandboxed executable, that traps because a process isn’t allowed to change its sandbox. And in your example Preview is sandboxed:

% codesign -d --entitlements - /System/Applications/Preview.app 
Executable=/System/Applications/Preview.app/Contents/MacOS/Preview
[Dict]
	…
    [Key] com.apple.security.app-sandbox
    [Value]
        [Bool] true
	…

You’ll see the same thing if you use posix_spawn to combine the fork and exec*.

This works if the new executable is not signed with the sandboxed entitlement. In that case it just inherits the static sandbox of the parent.

It also works if the new executable is signed with com.apple.security.app-sandbox and com.apple.security.inherit. That has the same effect, but it allows you to ship the executable embedded in an App Store app without App Review complaining that it’s not sandboxed.

I talk about this is more detail in Resolving App Sandbox Inheritance Problems.

Oh, and if you want to run a sandboxed app, don’t run it as a child process. Rather, run it using NSWorkspace.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Very helpful information, thank you.

I also found that when I enable App Sandbox there's another thread running. Without app sandbox there's just one thread. Could that affect fork? Crashing example that involves just fork with nothing else:

import Cocoa

class AppDelegate: NSObject, NSApplicationDelegate {
    var window: NSWindow?
}

func app() {
    let appDelegate = AppDelegate()
    let application = NSApplication.shared
    application.delegate = appDelegate
    print("launch app")
    let _ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)
    print("never here")
}

// without App Sandbox there's one thread at this point
// with App Sandbox there are two threads at this point
let pid = forkme()
if pid == -1 {
    fatalError("error")
} else if pid == 0 {
    print("after fork in child")
    app()
    print("exiting child")
} else {
    print("after fork in parent")
    usleep(10_000_000)
    print("exiting parent")
}

C helper:

pid_t forkme(void) {
    return fork();
}

Partial crash log:

System Integrity Protection: enabled

Crashed Thread:        0  Dispatch queue: com.apple.main-thread

Exception Type:        EXC_BREAKPOINT (SIGTRAP)
Exception Codes:       0x0000000000000001, 0x000000019984afac

Termination Reason:    Namespace SIGNAL, Code 5 Trace/BPT trap: 5
Terminating Process:   exc handler [47238]

Application Specific Information:
crashed on child side of fork pre-exec


Thread 0 Crashed::  Dispatch queue: com.apple.main-thread
0   libobjc.A.dylib                          0x19984afac objc_initializeAfterForkError + 0
1   libobjc.A.dylib                          0x199832958 initializeNonMetaClass + 1064
2   libobjc.A.dylib                          0x1998325bc initializeNonMetaClass + 140
3   libobjc.A.dylib                          0x19984cf14 initializeAndMaybeRelock(objc_class*, objc_object*, locker_mixin<lockdebug::lock_mixin<objc_lock_base_t>>&, bool) + 156
4   libobjc.A.dylib                          0x1998321c8 lookUpImpOrForward + 884
5   libobjc.A.dylib                          0x199831b64 _objc_msgSend_uncached + 68
6   For                                      0x104a68b68 app() + 76 (main.swift:43)
7   For                                      0x104a68488 main + 480 (main.swift:57)
8   dyld                                     0x199873f28 start + 2236

There's also this debug message in the console:

objc[47238]: +[NSResponder initialize] may have been in progress in another thread when fork() was called.
exiting parent

If I swap what's done in child and what's done in parent:

} else if pid == 0 {
    print("after fork in child")
    usleep(10_000_000)
    print("exiting child")
} else {
    print("after fork in parent")
    app()
    print("exiting parent")
}

then it works. So there's something I can and can't do in the child process? What are the rules? In one of the links above you mentioned that fork is fundamentally incompatible with "higher level frameworks"? Does that include, say, XPC? or URLSession? or Network framework?

Basically I want to make the old style "treadless" app (understandably the threads created by OS or standard library are probably inevitable): create N sub processes, off-load work to them from the main process and communicate to those processes by some means (I'm considering between pipes, XPC, Network framework and URLSession but I am open to consider a better alternative!).

To be clear, there are two separate issues in play here:

  • App Sandbox switching

  • fork without exec*

Your previous post was about the former, but now you’ve pivoted to the latter. That’s fine, but I want to highlight the change.


What are the rules?

The only rule I’m prepared to stand by is…

If you program in C or C++ and limit yourself to Posix APIs then fork without exec* should work reliably.

If you use Swift or Objective-C [1] or any high-level APIs, you open yourself up to binary compatibility problems.

In one of the links above you mentioned that fork is fundamentally incompatible with "higher level frameworks"? Does that include, say, XPC? or URLSession? or Network framework?

Yes, yes, and yes.

I want to … create N sub processes, off-load work to them from the main process

You have three options here:

  • Limit your language and API choice, as described above.

  • Do an exec* after the fork. It’s fine to exec* yourself. Or, better yet, using posix_spawn to combine these operations.

  • Embed a helper tool in your app and use posix_spawn to run instances of that.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] That is, not Objective-C or Swift. Over the weekend I posted an explanation as to why it’s not safe to use Objective-C or Swift between a fork and an exec*.

Your previous post was about the former, but now you’ve pivoted to the latter. That’s fine, but I want to highlight the change.

Yep. I was under the impression that fork would be faster than "Process().run" or posix_spawn, and if you are going to run a copy of "itself" it sounded the more "direct" approach.

Limit your language and API choice, as described above.

From what you told here and in the linked thread it does sound that even when my app is written in C and uses threads it won't be safe using fork (without immediate exec) due to exactly same reasons: some mutexes would stay locked and never unlocked, malloc memory state could be partly damaged at the time of fork.

I noticed that at the very beginning of the app written in Swift (in main.swift) I don't see threads created by the system or runtime. (Unless I enable App Sandbox – then I see another thread added, which is not doing much it seems) Do you think in that special case (at least without the sandbox where some secondary thread is created) it's fine using fork (without exec) in the swift app or are there still gotchas to look out for?

it does sound that even when my app is written in C and uses threads it won't be safe using fork (without immediate exec)

Well, in theory our low-level Posix APIs should work in this environment by leaning into pthread_atfork. I’ve never looked into this in depth. However, fork without exec* is fairly common in Unix-y code so, if this were failing systematically, I’d probably have heard about it.

Do you think in that special case … it's fine using fork (without exec) in the swift app … ?

No. I mean, it’ll probably work, but it’s relying on implementation details that could change.

I was under the impression that fork would be faster … if you are going to run a copy of "itself"

If your motivation is performance, I encourage you to run a performance test before going further down this path. That is, write a simple C program that can run children using fork without exec* or posix_spawn. Given that posix_spawn is the ‘hot path’ on Apple platforms, you might be surprised at the result [1]. Regardless, unless the test shows that fork without exec* offers a significant performance win, you can happily prune this entire line of exploration.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] To be clear, I’ve never run this test, so I’m not making a prediction.

No. I mean, it’ll probably work, but it’s relying on implementation details that could change.

In a way it feels even safer to fork in pure Swift app's main.swift file so long as this is done as the very first thing. In C++ app globals' constructors could create threads, mutexes, call malloc and whatnot before (and during) the app hitting main().

I encourage you to run a performance test

Indeed fork is not faster than posix_spawn, each took about 120 µs in a simple app. (I forked/spawned 1000 processes for accuracy).