- Sat 06 May 2023
- programming
- Gaige B. Paulsen
- #programming, #xcode, #macintosh, #automation
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 isSIGCHLD
being sent. However, I'm not comfortable ignoring it because I may not be the only one spawning a child task.