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

How to read all remaining output of readInBackgroundAndNotify after NSTask has ended?

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

异步读取其输出

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

这意味着我必须等待更长的时间,让 RunLoop 处理未决的 read 通知。 我如何知道我已读取该工具写入管道的所有内容?

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

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

注意:我寻求的解决方案不是使调用的工具超出某些文本结束标记。我相信必须有一些正确的方法来做到这一点,从而以某种方式指示管道流的末端,这就是我在答案中寻找的。

示例代码

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

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

而且我想保持 runModal 函数同步,即它在收到该工具的所有输出之前不应 return。它在我的实际程序中按自己的方式执行 运行,如果这很重要(我看到 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);
}

很简单。在观察者块中,当 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);
}

waitUntilExit 的问题在于它的行为并不总是像人们想象的那样。 documenation中提到以下内容:

waitUntilExit does not guarantee that the terminationHandler block has been fully executed before waitUntilExit returns.

看来这正是您遇到的问题;这是一个竞争条件。 waitUntilExit 等待的时间不够长,lineCount 变量在 NSTask 完成之前到达。解决方案可能是使用 semaphoredispatch_group,尽管不清楚您是否想走那条路——这似乎不是一个容易解决的问题。

*几个月前我遇到过类似的问题,不幸的是仍然没有解决