NSTask 结束后如何读取 readInBackgroundAndNotify 的所有剩余输出?

Tho*_*ann 5 macos nstask nsfilehandle nspipe

我正在通过NSTask. 这些工具可能会运行几秒钟,并不断地将文本输出到stdout. 最终,该工具将自行终止。我的应用程序使用readInBackgroundAndNotify.

如果我在工具退出后立即停止处理异步输出,我通常会丢失一些当时尚未交付的输出。

这意味着我必须等待更长的时间,以允许 RunLoop 处理挂起的读取通知。我如何知道我何时阅读了该工具写入管道的所有内容?

这个问题可以在下面的代码中通过删除带有runMode:调用的行来验证- 然后程序将打印零行已处理。所以看起来在进程退出时,队列中已经有一个等待传递的通知,并且传递是通过runMode:调用发生的。

现在,看起来在工具退出后简单地调用runMode: 一次可能就足够了,但我的测试表明它不是 - 有时(具有大量输出数据),这仍然只会处理剩余数据的一部分。

注意:诸如使调用的工具输出某些文本结束标记之类的变通方法不是我寻求的解决方案。我相信必须有一些适当的方法来做到这一点,从而以某种方式发出管道流的结束信号,这就是我在答案中寻找的。

示例代码

下面的代码可以粘贴到一个新的 Xcode 项目AppDelegate.m文件中。

运行时,它调用一个工具来生成一些更长的输出,然后等待工具终止waitUntilExit。如果它随后立即删除outputFileHandleReadCompletionObserver,则将错过该工具的大部分输出。通过添加runMode:持续一秒的调用,接收工具的所有输出——当然,这个定时循环不是最佳的。

而且我想保持runModal函数同步,即它在收到工具的所有输出之前不会返回。它确实在我的实际程序中以自己的方式运行,如果这很重要(我看到 Peter Hosey 的评论警告waitUntilExit会阻止 UI,但在我的情况下这不是问题)。

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
{
    [self runTool];
}

- (void)runTool
{
    // Retrieve 200 lines of text by invoking `head -n 200 /usr/share/dict/words`
    NSTask *theTask = [[NSTask alloc] init];
    theTask.qualityOfService = NSQualityOfServiceUserInitiated;
    theTask.launchPath = @"/usr/bin/head";
    theTask.arguments = @[@"-n", @"200", @"/usr/share/dict/words"];

    __block int lineCount = 0;

    NSPipe *outputPipe = [NSPipe pipe];
    theTask.standardOutput = outputPipe;
    NSFileHandle *outputFileHandle = outputPipe.fileHandleForReading;
    NSString __block *prevPartialLine = @"";
    id <NSObject> outputFileHandleReadCompletionObserver = [[NSNotificationCenter defaultCenter] addObserverForName:NSFileHandleReadCompletionNotification object:outputFileHandle queue:nil usingBlock:^(NSNotification * _Nonnull note)
    {
        // Read the output from the cmdline tool
        NSData *data = [note.userInfo objectForKey:NSFileHandleNotificationDataItem];
        if (data.length > 0) {
            // go over each line
            NSString *output = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
            NSArray *lines = [[prevPartialLine stringByAppendingString:output] componentsSeparatedByString:@"\n"];
            prevPartialLine = [lines lastObject];
            NSInteger lastIdx = lines.count - 1;
            [lines enumerateObjectsUsingBlock:^(NSString *line, NSUInteger idx, BOOL * _Nonnull stop) {
                if (idx == lastIdx) return; // skip the last (= incomplete) line as it's not terminated by a LF
                // now we can process `line`
                lineCount += 1;
            }];
        }
        [note.object readInBackgroundAndNotify];
    }];

    NSParameterAssert(outputFileHandle);
    [outputFileHandle readInBackgroundAndNotify];

    // Start the task
    [theTask launch];

    // Wait until it is finished
    [theTask waitUntilExit];

    // Wait one more second so that we can process any remaining output from the tool
    NSDate *endDate = [NSDate dateWithTimeIntervalSinceNow:1];
    while ([NSDate.date compare:endDate] == NSOrderedAscending) {
        [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
    }

    [[NSNotificationCenter defaultCenter] removeObserver:outputFileHandleReadCompletionObserver];

    NSLog(@"Lines processed: %d", lineCount);
}
Run Code Online (Sandbox Code Playgroud)

vad*_*ian 1

这很简单。在观察者块中,当data.length为 0 时,删除观察者并调用terminate.

该代码将在该行之后继续waitUntilExit

- (void)runTool
{
    // Retrieve 20000 lines of text by invoking `head -n 20000 /usr/share/dict/words`
    const int expected = 20000;
    NSTask *theTask = [[NSTask alloc] init];
    theTask.qualityOfService = NSQualityOfServiceUserInitiated;
    theTask.launchPath = @"/usr/bin/head";
    theTask.arguments = @[@"-n", [@(expected) stringValue], @"/usr/share/dict/words"];

    __block int lineCount = 0;
    __block bool finished = false;

    NSPipe *outputPipe = [NSPipe pipe];
    theTask.standardOutput = outputPipe;
    NSFileHandle *outputFileHandle = outputPipe.fileHandleForReading;
    NSString __block *prevPartialLine = @"";
    [[NSNotificationCenter defaultCenter] addObserverForName:NSFileHandleReadCompletionNotification object:outputFileHandle queue:nil usingBlock:^(NSNotification * _Nonnull note)
    {
        // Read the output from the cmdline tool
        NSData *data = [note.userInfo objectForKey:NSFileHandleNotificationDataItem];
        if (data.length > 0) {
            // go over each line
            NSString *output = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
            NSArray *lines = [[prevPartialLine stringByAppendingString:output] componentsSeparatedByString:@"\n"];
            prevPartialLine = [lines lastObject];
            NSInteger lastIdx = lines.count - 1;
            [lines enumerateObjectsUsingBlock:^(NSString *line, NSUInteger idx, BOOL * _Nonnull stop) {
                if (idx == lastIdx) return; // skip the last (= incomplete) line as it's not terminated by a LF
                // now we can process `line`
                lineCount += 1;
            }];
        } else {
            [[NSNotificationCenter defaultCenter] removeObserver:self name:NSFileHandleReadCompletionNotification object:nil];
            [theTask terminate];
            finished = true;
        }
        [note.object readInBackgroundAndNotify];
    }];

    NSParameterAssert(outputFileHandle);
    [outputFileHandle readInBackgroundAndNotify];

    // Start the task
    [theTask launch];

    // Wait until it is finished
    [theTask waitUntilExit];

    // Wait until all data from the pipe has been received
    while (!finished) {
        [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.0001]];
    }

    NSLog(@"Lines processed: %d (should be: %d)", lineCount, expected);
}
Run Code Online (Sandbox Code Playgroud)