Subtasks and Redirection


Background

As part of an ongoing effort to keep Cartographica up to date with recent changes in libraries that we compile from source, notably GDAL and Proj, I'm in the midst of a refresh of those subtrees in the frameworks that I build from them. Over the past few years, both of these projects have expanded test coverage and modernized their build architectures (using CMake) and I've improved validation and coverage by integrating these tests into my Xcode build environment.

Up until the Cartographica 1.6 release, where I made available the Command Line Tools for GDAL and PROJ, I didn't have a way to do acceptance testing on the final product, so I integrated these tests into the unit tests for the frameworks.

A Problem with Shell Redirection

For many of the tests for both PROJ especially, the tests involve invoking CLI commands with a set of parameters and validating that the exact results are as expected. In order to support this, I created an Objective-C class that spawns a /bin/sh shell (although the specific flavor doesn't seem to have much effect on the problem) using the executable-bit-marked shell script as arg0 with the necessary arguments and environment variables in place.

This has worked well since I build this structure in 2014. However, the most recent updates elicited failures based on the diffs in the tests not succeeding. First check was to run the test manually, which resulted in... succcess. That was a bit unexpected, since I'm running the same commands in effectively the same environment in both cases...but, of course, it is not.

To run the tests from within the XCTest structure, I am running in code, and that means that I need to spawn the task using a sub-shell, which in my case involves spawning an NSTask, and waiting for it to complete in order to gather the results.

Looking at the results, the key difference is that when run in my NSTask, the redirection of the stdout and stderr to the same location in the script works differently than it does from the command line. When run from the command line, they are separately buffered, causing the results to appear as:

Attempt to use coordinate operation Inverse of WGS 84 to EGM2008 height (1) failed.
49 2 0  *  * inf

When run inside of the NSTask, the results are a less useful:

49 2 0 Attempt to use coordinate operation Inverse of WGS 84 to EGM2008 height (1) failed.
*  * inf

The code for the underlying command echos to stdout the initial coordinates (49 2 0 ) before the error occurs, then sends the error to stderr and then continue to print the result to stderr, including the \n, signalling EOL, and flushing the buffer.

It's not at all clear why the behavior of the buffering is working differently during the script executed from withing the shell directly rather than from the script executed from within the NSTask. In this case, the actual redirection happens as part of the script and not part of the original shell from which the script is being run. I speculate that there's some kind of default handlign that is getting passed through to the script from the original shell, and when I use NSTask it is coming from there instead.

The code is pretty straigthforward:

- (int)runScriptTest:(NSString*)script withExecutable:(NSString*)executable andArguments:(NSArray<NSString*>* _Nullable)userArguments
{
    NSBundle *testBundle =[NSBundle bundleForClass: [self class]];
    NSString *executablePath = [testBundle pathForAuxiliaryExecutable: executable];
    XCTAssertNotNil( executablePath, @"Need executable %@", executable);

    NSString *scriptPath = [testBundle pathForResource: script ofType:nil];
    XCTAssertNotNil( scriptPath, @"Need script %@", script);
    
    NSString *runDir = NSProcessInfo.processInfo.environment[@"TMPDIR"];
    XCTAssertNotNil( runDir, @"Need runPath %@", script);
    XCTAssertNotEqualObjects(runDir, @"/");
    NSTask *childTask = [[NSTask alloc] init];
    
    NSArray *arguments = @[scriptPath, executablePath, self.nadPath];
    if (userArguments)
        arguments = [arguments arrayByAddingObjectsFromArray: userArguments];
    childTask.arguments = arguments;
    childTask.executableURL = [NSURL fileURLWithPath: @"/bin/sh"];
    childTask.currentDirectoryURL = [NSURL fileURLWithPath: runDir];
    childTask.environment = [self environmentWithResources];
    
    NSError *error;
    XCTAssertTrue([childTask launchAndReturnError: &error], @"Launch failed %@", error);
    
    [childTask waitUntilExit];
    int status = [childTask terminationStatus];
    
    return status;
}

A minimal C program doesn't have any problem with this:

#include <unistd.h>
#include <stdio.h>
#include <string.h>

int main(int argc, char **argv)
{
	const char *env[] = {
		"PROJ_DATA=Proj4Tests.xctest/Contents/Resources/for_tests",
		NULL
	};

    int result;
	result = execle( "/bin/sh", "/bin/sh", "Proj4Tests.xctest/Contents/Resources/testvarious", "Proj4Tests.xctest/Contents/MacOS/cs2cs", "Proj4Tests.xctest/Contents/Resources/for_tests", NULL, env);
	printf("result = %d (%s)", result, strerror(result));
}

A solution by replacing NSTask

Doing some further experimentation, I don't end up with interleaved output on the subprocess is I use posix_spawn instead of spawning with NSTask.

Adapting my original code, this seems to work:

- (int)runScriptTest:(NSString*)script withExecutable:(NSString*)executable andArguments:(NSArray<NSString*>* _Nullable)userArguments
{
    NSBundle *testBundle =[NSBundle bundleForClass: [self class]];
    NSString *executablePath = [testBundle pathForAuxiliaryExecutable: executable];
    XCTAssertNotNil( executablePath, @"Need executable %@", executable);

    NSString *scriptPath = [testBundle pathForResource: script ofType:nil];
    XCTAssertNotNil( scriptPath, @"Need script %@", script);
    
    NSString *runDir = NSProcessInfo.processInfo.environment[@"TMPDIR"];
    XCTAssertNotNil( runDir, @"Need runPath %@", script);
    XCTAssertNotEqualObjects(runDir, @"/");
    
    NSArray *arguments = @[scriptPath, executablePath, self.nadPath];
    if (userArguments)
        arguments = [arguments arrayByAddingObjectsFromArray: userArguments];
    
    const char * const env[] = {
        [NSString stringWithFormat: @"PROJ_DATA=%@", [self.nadPath stringByAppendingPathComponent:@"for_tests"]].UTF8String,
        NULL
    };
    
    const char * const args[] = {
        "/bin/sh",
        scriptPath.UTF8String,
        executablePath.UTF8String,
        self.nadPath.UTF8String,
        NULL
    };
    
    pthread_chdir_np(runDir.UTF8String);
    
    pid_t pid;
    int success = posix_spawn( &pid, "/bin/sh", NULL, NULL, (char *const*)args, (char *const*)env);
    if (success==-1) {
        printf("Fork failed");
        return(-1);
    }

    printf("parent sees child %d\n", pid);
    int status;
    pid = waitpid(pid, &status, 0);
    if (pid<0) {
        if (errno == EINTR) {
            pid = waitpid(pid, &status, 0);
        }
        if (pid<0) {
            printf("Error waiting %d %d\n", pid, errno);
            return(-2);
        }
    }
    printf("child completed with %d\n", status);
    return status;
}

Two things of note here:

  • pthread_chdir_np is not a public method for macOS -- other third party applications, like Chrome use it, but it's not sanctioned and could go away. I'm less concerned about this in a test jig than in code that would go to end users.
  • The little dance around waitpid being called twice is related to receiving a signal, which I am pretty certain is SIGCHLD being sent. However, I'm not comfortable ignoring it because I may not be the only one spawning a child task.