413 lines
22 KiB
Plaintext
413 lines
22 KiB
Plaintext
#include "../Sources/Core/progress_reporter.hpp"
|
|
#import <Foundation/Foundation.h>
|
|
#include <atomic>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <limits>
|
|
#include <signal.h>
|
|
#include <spawn.h>
|
|
#include <string>
|
|
#include <stdexcept>
|
|
#include <sys/resource.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/wait.h>
|
|
#include <thread>
|
|
#include <unistd.h>
|
|
|
|
extern char **environ;
|
|
using playback::ProgressReporter;
|
|
static const std::string firstURL = "https://www.youtube.com/watch?v=AAAAAAAAAAA";
|
|
static const std::string secondURL = "https://www.youtube.com/watch?v=BBBBBBBBBBB";
|
|
|
|
static void require(bool condition, const char *message) {
|
|
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
|
|
}
|
|
|
|
static PlayerState sample(PlayerStatus status, double position, double duration = 100) {
|
|
PlayerState state{};
|
|
state.status = status;
|
|
state.position = position;
|
|
state.duration = duration;
|
|
state.has_audio = state.has_video = state.can_seek = true;
|
|
return state;
|
|
}
|
|
|
|
static NSString *nativePath(const std::string &path) {
|
|
return [NSString stringWithUTF8String:path.c_str()];
|
|
}
|
|
|
|
static NSArray<NSDictionary *> *records(const std::string &path) {
|
|
NSString *text = [NSString stringWithContentsOfFile:nativePath(path)
|
|
encoding:NSUTF8StringEncoding error:nullptr];
|
|
require(text != nil && [text hasSuffix:@"\n"], "finished JSONL has a complete final line");
|
|
NSMutableArray<NSDictionary *> *result = [NSMutableArray array];
|
|
NSArray<NSString *> *lines = [text componentsSeparatedByString:@"\n"];
|
|
NSSet *keys = [NSSet setWithArray:@[@"version", @"event", @"media_url", @"position_seconds",
|
|
@"duration_seconds", @"completion_eligible"]];
|
|
for (NSUInteger i = 0; i + 1 < lines.count; ++i) {
|
|
id object = [NSJSONSerialization JSONObjectWithData:[lines[i] dataUsingEncoding:NSUTF8StringEncoding]
|
|
options:0 error:nullptr];
|
|
require([object isKindOfClass:[NSDictionary class]], "consumer decodes every JSONL record");
|
|
NSDictionary *row = object;
|
|
require([[NSSet setWithArray:row.allKeys] isEqual:keys], "records have exactly the six specified fields");
|
|
require([row[@"version"] isKindOfClass:[NSNumber class]] && [row[@"version"] intValue] == 1,
|
|
"schema version is numeric one");
|
|
require([@[@"progress", @"ended", @"closed"] containsObject:row[@"event"]], "known event type");
|
|
require([row[@"media_url"] isKindOfClass:[NSString class]] &&
|
|
ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "canonical consumer identity");
|
|
require([row[@"position_seconds"] isKindOfClass:[NSNumber class]] &&
|
|
std::isfinite([row[@"position_seconds"] doubleValue]) &&
|
|
[row[@"position_seconds"] doubleValue] >= 0, "position is finite and nonnegative");
|
|
id duration = row[@"duration_seconds"];
|
|
require(duration == [NSNull null] || ([duration isKindOfClass:[NSNumber class]] &&
|
|
std::isfinite([duration doubleValue]) && [duration doubleValue] > 0), "duration is positive or null");
|
|
require(CFGetTypeID((__bridge CFTypeRef)row[@"completion_eligible"]) == CFBooleanGetTypeID(),
|
|
"eligibility is a JSON boolean");
|
|
[result addObject:row];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
static void expect(NSDictionary *row, NSString *event, const std::string &url,
|
|
double position, bool eligible, double duration = 100) {
|
|
require([row[@"event"] isEqual:event] && [row[@"media_url"] isEqual:nativePath(url)] &&
|
|
[row[@"position_seconds"] doubleValue] == position &&
|
|
[row[@"completion_eligible"] boolValue] == eligible, "event has the acknowledged consumer snapshot");
|
|
require(duration > 0 && std::isfinite(duration) ? [row[@"duration_seconds"] doubleValue] == duration :
|
|
row[@"duration_seconds"] == [NSNull null], "duration retains precision or is null");
|
|
}
|
|
|
|
static bool rejected(const std::string &path) {
|
|
try { ProgressReporter reporter(path); }
|
|
catch (const std::exception &error) { return error.what()[0] != '\0'; }
|
|
return false;
|
|
}
|
|
|
|
// An actual OS write failure, isolated so the resource limit cannot affect the parent.
|
|
static int writeFailure(const std::string &path) {
|
|
require(signal(SIGXFSZ, SIG_IGN) != SIG_ERR, "ignore file-size-limit signal");
|
|
struct rlimit limit{1, 1};
|
|
require(setrlimit(RLIMIT_FSIZE, &limit) == 0, "install deterministic one-byte file limit");
|
|
ProgressReporter reporter(path, [] { return 0.; });
|
|
reporter.begin(firstURL);
|
|
reporter.observe(sample(PLAYER_PLAYING, 1));
|
|
reporter.finish();
|
|
auto error = reporter.error();
|
|
require(!error.empty(), "OS write failure is persistently reported without throwing");
|
|
reporter.begin(secondURL);
|
|
reporter.observe(sample(PLAYER_ENDED, 100));
|
|
reporter.finish();
|
|
require(reporter.error() == error, "failure remains available after repeated finish and observations");
|
|
struct stat info{};
|
|
require(stat(path.c_str(), &info) == 0 && info.st_size == 1,
|
|
"partial write is retained and failed output stays disabled");
|
|
return 0;
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
if (argc == 3 && std::string(argv[1]) == "--write-failure") return writeFailure(argv[2]);
|
|
@autoreleasepool {
|
|
char directory[] = "/tmp/terebi-progress-reporting-XXXXXX";
|
|
require(mkdtemp(directory) != nullptr, "create isolated output directory");
|
|
const std::string root(directory);
|
|
double now = 100;
|
|
const auto path = root + "/cadence.jsonl";
|
|
{
|
|
ProgressReporter reporter(path, [&] { return now; });
|
|
reporter.begin(firstURL);
|
|
reporter.observe(sample(PLAYER_OPENING, 99));
|
|
reporter.observe(sample(PLAYER_ERROR, 99));
|
|
auto pending = sample(PLAYER_PAUSED, 99);
|
|
pending.seek_pending = true;
|
|
reporter.observe(pending);
|
|
reporter.observe(sample(PLAYER_PLAYING, 1.2345678901234567));
|
|
now = 104.999;
|
|
reporter.observe(sample(PLAYER_PLAYING, 4));
|
|
now = 105;
|
|
reporter.observe(sample(PLAYER_PLAYING, 6.25));
|
|
now = 106;
|
|
pending = sample(PLAYER_PLAYING, 99);
|
|
pending.seek_pending = true;
|
|
reporter.observe(pending);
|
|
now = 107;
|
|
reporter.observe(sample(PLAYER_PLAYING, 95));
|
|
now = 108;
|
|
reporter.observe(sample(PLAYER_PLAYING, 96));
|
|
reporter.observe(sample(PLAYER_PAUSED, 96.5));
|
|
pending = sample(PLAYER_PAUSED, 50);
|
|
pending.seek_pending = true;
|
|
reporter.observe(pending);
|
|
reporter.observe(sample(PLAYER_PAUSED, 50));
|
|
now = 200;
|
|
reporter.observe(sample(PLAYER_PAUSED, 97));
|
|
reporter.observe(sample(PLAYER_PAUSED, std::numeric_limits<double>::quiet_NaN()));
|
|
pending.position = 99;
|
|
reporter.observe(pending);
|
|
reporter.closeMedia();
|
|
reporter.closeMedia();
|
|
reporter.finish();
|
|
reporter.finish();
|
|
require(reporter.error().empty(), "normal output drains without errors");
|
|
}
|
|
auto rows = records(path);
|
|
require(rows.count == 6, "initial, exact period, acknowledged seek, pause, backward seek, and one close only");
|
|
expect(rows[0], @"progress", firstURL, 1.2345678901234567, true);
|
|
expect(rows[1], @"progress", firstURL, 6.25, true);
|
|
expect(rows[2], @"progress", firstURL, 95, true);
|
|
expect(rows[3], @"progress", firstURL, 96.5, true);
|
|
expect(rows[4], @"progress", firstURL, 50, true);
|
|
expect(rows[5], @"closed", firstURL, 97, true);
|
|
|
|
const auto identityPath = root + "/identity.jsonl";
|
|
now = 0;
|
|
{
|
|
ProgressReporter reporter(identityPath, [&] { return now; });
|
|
reporter.begin(firstURL);
|
|
reporter.observe(sample(PLAYER_PLAYING, 10));
|
|
now = 1;
|
|
reporter.observe(sample(PLAYER_PLAYING, 96));
|
|
reporter.begin(firstURL);
|
|
reporter.observe(sample(PLAYER_OPENING, 0));
|
|
now = 2;
|
|
reporter.observe(sample(PLAYER_PAUSED, 96));
|
|
now = 2.1;
|
|
reporter.observe(sample(PLAYER_PLAYING, 20));
|
|
now = 3;
|
|
reporter.observe(sample(PLAYER_PLAYING, 96));
|
|
reporter.begin(secondURL);
|
|
reporter.observe(sample(PLAYER_PLAYING, 90));
|
|
now = 3.1;
|
|
reporter.observe(sample(PLAYER_PLAYING, 96));
|
|
now = 4;
|
|
reporter.observe(sample(PLAYER_ENDED, 100));
|
|
reporter.observe(sample(PLAYER_ENDED, 0));
|
|
reporter.begin("");
|
|
reporter.observe(sample(PLAYER_PLAYING, 10));
|
|
reporter.finish();
|
|
}
|
|
rows = records(identityPath);
|
|
require(rows.count == 8, "quality reopen preserves threshold while replacement resets identity and closes each source");
|
|
expect(rows[0], @"progress", firstURL, 10, true);
|
|
expect(rows[1], @"progress", firstURL, 96, true);
|
|
expect(rows[2], @"progress", firstURL, 96, true);
|
|
expect(rows[3], @"closed", firstURL, 96, true);
|
|
expect(rows[4], @"progress", secondURL, 90, true);
|
|
expect(rows[5], @"progress", secondURL, 96, true);
|
|
expect(rows[6], @"ended", secondURL, 100, true);
|
|
expect(rows[7], @"closed", secondURL, 100, true);
|
|
|
|
const auto terminalPath = root + "/terminal.jsonl";
|
|
now = 0;
|
|
{
|
|
ProgressReporter reporter(terminalPath, [&] { return now; });
|
|
reporter.begin(firstURL);
|
|
reporter.observe(sample(PLAYER_PLAYING, 94));
|
|
now = 1;
|
|
reporter.observe(sample(PLAYER_PAUSED, 95));
|
|
reporter.closeMedia();
|
|
reporter.begin(secondURL);
|
|
reporter.observe(sample(PLAYER_PLAYING, 94));
|
|
reporter.observe(sample(PLAYER_ENDED, 95));
|
|
reporter.finish();
|
|
}
|
|
rows = records(terminalPath);
|
|
require(rows.count == 6, "pause/ended threshold crossings do not add redundant progress events");
|
|
expect(rows[1], @"progress", firstURL, 95, true);
|
|
expect(rows[2], @"closed", firstURL, 95, true);
|
|
expect(rows[4], @"ended", secondURL, 95, true);
|
|
expect(rows[5], @"closed", secondURL, 95, true);
|
|
|
|
const auto eligibilityPath = root + "/eligibility.jsonl";
|
|
{
|
|
ProgressReporter reporter(eligibilityPath, [] { return 0.; });
|
|
for (int scenario = 0; scenario < 7; ++scenario) {
|
|
auto state = sample(PLAYER_PAUSED, 97);
|
|
if (scenario == 0) state.duration = 0;
|
|
if (scenario == 1) state.duration = -1;
|
|
if (scenario == 2) state.duration = std::numeric_limits<double>::infinity();
|
|
if (scenario == 3) state.duration = std::numeric_limits<double>::quiet_NaN();
|
|
if (scenario == 5) state.can_seek = false;
|
|
if (scenario == 6) state.has_video = false;
|
|
reporter.begin(firstURL, scenario == 4);
|
|
reporter.observe(state);
|
|
reporter.closeMedia();
|
|
}
|
|
reporter.finish();
|
|
}
|
|
rows = records(eligibilityPath);
|
|
require(rows.count == 14, "every ineligible source has an initial and terminal snapshot");
|
|
for (NSUInteger i = 0; i < rows.count; ++i)
|
|
expect(rows[i], i % 2 ? @"closed" : @"progress", firstURL, 97, false, i < 8 ? 0 : 100);
|
|
|
|
require(ProgressReporter::validMediaURL(firstURL) &&
|
|
ProgressReporter::validMediaURL("https://www.youtube.com/watch?v=aA0_-123456"), "eleven ASCII ID characters are accepted");
|
|
for (const char *invalid : {"", "http://www.youtube.com/watch?v=AAAAAAAAAAA",
|
|
"https://youtube.com/watch?v=AAAAAAAAAAA", "https://www.youtube.com/watch?v=AAAAAAAAAA",
|
|
"https://www.youtube.com/watch?v=AAAAAAAAAAAA", "https://www.youtube.com/watch?v=AAAAAAAAAAA&x=1",
|
|
"https://www.youtube.com/watch?v=AAAAAAAAAA%", "https://www.youtube.com/watch?v=AAAAAAAAAA\n"})
|
|
require(!ProgressReporter::validMediaURL(invalid), "noncanonical URLs are rejected");
|
|
|
|
require(ProgressReporter::validMediaIdentity("miruro:opaque_A-1:12") &&
|
|
ProgressReporter::validMediaIdentity("miruro:a:2147483647"), "strict Miruro identities accepted");
|
|
for (const char *invalid : {"miruro::1", "miruro:a:0", "miruro:a:01", "miruro:a:-1",
|
|
"miruro:a:1.5", "miruro:a:2147483648", "miruro:a:1?token=secret",
|
|
"miruro:a:b:1", "miruro:a:1\n", "miruro:bad/id:1", "miruro:é:1"})
|
|
require(!ProgressReporter::validMediaIdentity(invalid), "ambiguous or token-bearing identities rejected");
|
|
const auto miruroPath = root + "/miruro.jsonl";
|
|
{
|
|
ProgressReporter reporter(miruroPath, [] { return 0.; });
|
|
reporter.begin("miruro:opaque_A-1:12");
|
|
reporter.observe(sample(PLAYER_PLAYING, 95));
|
|
reporter.begin("miruro:opaque_A-1:13");
|
|
reporter.observe(sample(PLAYER_ENDED, 100));
|
|
reporter.finish();
|
|
require(reporter.error().empty(), "Miruro progress drains successfully");
|
|
}
|
|
NSString *miruroText = [NSString stringWithContentsOfFile:nativePath(miruroPath)
|
|
encoding:NSUTF8StringEncoding error:nullptr];
|
|
NSArray<NSString *> *miruroLines = [miruroText componentsSeparatedByString:@"\n"];
|
|
require(miruroLines.count == 5, "Miruro replacement emits progress, close, ended, close");
|
|
NSSet *miruroKeys = [NSSet setWithArray:@[@"version", @"event", @"media_identity",
|
|
@"position_seconds", @"duration_seconds", @"completion_eligible"]];
|
|
for (NSUInteger i = 0; i < 4; ++i) {
|
|
NSDictionary *row = [NSJSONSerialization JSONObjectWithData:[miruroLines[i]
|
|
dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nullptr];
|
|
require([row isKindOfClass:[NSDictionary class]] &&
|
|
[[NSSet setWithArray:row.allKeys] isEqual:miruroKeys] &&
|
|
[row[@"version"] intValue] == 2, "Miruro schema is v2 without media_url");
|
|
NSDictionary *identity = row[@"media_identity"];
|
|
require([identity isKindOfClass:[NSDictionary class]] &&
|
|
[[NSSet setWithArray:identity.allKeys] isEqual:
|
|
[NSSet setWithArray:@[@"provider", @"media_id", @"episode"]]] &&
|
|
[identity[@"provider"] isEqual:@"miruro"] &&
|
|
[identity[@"media_id"] isEqual:@"opaque_A-1"] &&
|
|
[identity[@"episode"] intValue] == (i < 2 ? 12 : 13),
|
|
"structured stable episode identity contains no CDN token");
|
|
require([row[@"event"] isEqual:(i == 0 ? @"progress" : i == 2 ? @"ended" : @"closed")] &&
|
|
[row[@"position_seconds"] doubleValue] == (i < 2 ? 95 : 100) &&
|
|
[row[@"completion_eligible"] boolValue], "Miruro retains terminal snapshot semantics");
|
|
}
|
|
|
|
const auto clockFailurePath = root + "/clock-failure.jsonl";
|
|
{
|
|
ProgressReporter reporter(clockFailurePath, []() -> double { throw std::runtime_error("clock failure"); });
|
|
reporter.begin(firstURL);
|
|
reporter.observe(sample(PLAYER_PLAYING, 1));
|
|
reporter.closeMedia();
|
|
reporter.finish();
|
|
}
|
|
require([[NSData dataWithContentsOfFile:nativePath(clockFailurePath)] length] == 0,
|
|
"a reporting exception cannot emit later close records");
|
|
|
|
const auto privatePath = root + "/private.jsonl";
|
|
mode_t oldMask = umask(0777);
|
|
{ ProgressReporter reporter(privatePath); umask(oldMask); reporter.finish(); }
|
|
struct stat info{};
|
|
require(stat(privatePath.c_str(), &info) == 0 && S_ISREG(info.st_mode) &&
|
|
(info.st_mode & 0777) == 0600 && info.st_uid == geteuid(), "new destination is regular and exactly owner-private despite umask");
|
|
const auto existingPath = root + "/existing.jsonl";
|
|
NSData *sentinel = [@"unchanged" dataUsingEncoding:NSUTF8StringEncoding];
|
|
require([sentinel writeToFile:nativePath(existingPath) atomically:NO], "create existing destination sentinel");
|
|
require(rejected(existingPath), "existing destination fails exclusive initialization");
|
|
const auto linkPath = root + "/symlink.jsonl";
|
|
require(symlink(existingPath.c_str(), linkPath.c_str()) == 0 && rejected(linkPath), "symlink destination fails initialization");
|
|
require([[NSData dataWithContentsOfFile:nativePath(existingPath)] isEqual:sentinel], "rejection never changes an existing destination");
|
|
|
|
const auto queueFirst = root + "/queue-first.jsonl", queueSecond = root + "/queue-second.jsonl";
|
|
{
|
|
playback::ProgressReports reports;
|
|
reports.registerFile(firstURL, queueFirst);
|
|
reports.registerFile(secondURL, queueSecond);
|
|
reports.begin(firstURL);
|
|
reports.observe(sample(PLAYER_PAUSED, 12));
|
|
reports.begin(secondURL);
|
|
reports.observe(sample(PLAYER_ENDED, 100));
|
|
reports.begin(firstURL);
|
|
reports.observe(sample(PLAYER_PAUSED, 24));
|
|
reports.begin("miruro:unregistered:1");
|
|
reports.observe(sample(PLAYER_PAUSED, 55));
|
|
reports.finish();
|
|
require(reports.error().empty(), "queue reports can revisit their owned session without reopening arbitrary files");
|
|
}
|
|
auto firstRows = records(queueFirst), secondRows = records(queueSecond);
|
|
require(firstRows.count == 4 && secondRows.count == 2, "queue switches drain old reports and revisits append");
|
|
expect(firstRows[0], @"progress", firstURL, 12, true);
|
|
expect(firstRows[2], @"progress", firstURL, 24, true);
|
|
expect(secondRows[0], @"ended", secondURL, 100, true);
|
|
const auto liveSingle = root + "/live-single.jsonl", liveNext = root + "/live-next.jsonl";
|
|
const auto rollbackPath = root + "/rollback.jsonl";
|
|
{
|
|
playback::ProgressReports reports(liveSingle);
|
|
reports.begin(firstURL);
|
|
reports.observe(sample(PLAYER_PAUSED, 7));
|
|
const char *identities[] = {secondURL.c_str()}, *destinations[] = {liveNext.c_str()};
|
|
reports.registerFiles(identities, destinations, 1);
|
|
reports.observe(sample(PLAYER_PAUSED, 8));
|
|
const char *conflictingIDs[] = {"miruro:new:1", secondURL.c_str()};
|
|
const auto unused = root + "/unused.jsonl";
|
|
const char *conflictingPaths[] = {rollbackPath.c_str(), unused.c_str()};
|
|
bool refused = false;
|
|
try { reports.registerFiles(conflictingIDs, conflictingPaths, 2); } catch (...) { refused = true; }
|
|
require(refused, "conflicting later entry rejects the complete registration batch");
|
|
reports.registerFile("miruro:new:1", rollbackPath);
|
|
reports.begin(secondURL);
|
|
reports.observe(sample(PLAYER_PAUSED, 3));
|
|
reports.begin(firstURL);
|
|
reports.observe(sample(PLAYER_PAUSED, 9));
|
|
reports.begin("miruro:unregistered:1");
|
|
reports.observe(sample(PLAYER_PAUSED, 55));
|
|
refused = false;
|
|
try { reports.registerFile(firstURL, unused); } catch (...) { refused = true; }
|
|
require(refused, "enqueue cannot rebind the original single identity after its queue row is removed");
|
|
reports.finish();
|
|
require(reports.error().empty(), "enqueue preserves single reporting and supports owned report revisits");
|
|
}
|
|
auto singleRows = records(liveSingle), nextRows = records(liveNext);
|
|
require(singleRows.count >= 4 && nextRows.count == 2, "active single report survives registration and queued report stays isolated");
|
|
for (NSDictionary *row in singleRows) require([row[@"media_url"] isEqual:nativePath(firstURL)], "queued identity never leaks into original single report");
|
|
for (NSDictionary *row in nextRows) require([row[@"media_url"] isEqual:nativePath(secondURL)], "queued report has only its registered identity");
|
|
const auto replacedPath = root + "/replaced.jsonl";
|
|
{
|
|
ProgressReporter reporter(replacedPath, [] { return 100.; });
|
|
reporter.begin(firstURL);
|
|
reporter.observe(sample(PLAYER_PAUSED, 1));
|
|
reporter.finish();
|
|
require(rename(replacedPath.c_str(), (replacedPath + ".original").c_str()) == 0, "retain original inode while replacing path");
|
|
require([sentinel writeToFile:nativePath(replacedPath) atomically:NO], "replace report with unrelated sentinel");
|
|
bool refused = false;
|
|
try { reporter.resume(); } catch (...) { refused = true; }
|
|
require(refused && [[NSData dataWithContentsOfFile:nativePath(replacedPath)] isEqual:sentinel],
|
|
"revisiting never appends to a replaced report inode");
|
|
}
|
|
|
|
const auto concurrentPath = root + "/concurrent.jsonl";
|
|
std::atomic<int> ready{0}, successes{0}, failures{0};
|
|
std::atomic<bool> start{false};
|
|
auto contender = [&] {
|
|
ready.fetch_add(1);
|
|
while (!start.load()) std::this_thread::yield();
|
|
try { ProgressReporter reporter(concurrentPath); ++successes; reporter.finish(); }
|
|
catch (const std::exception &) { ++failures; }
|
|
};
|
|
std::thread left(contender), right(contender);
|
|
while (ready.load() != 2) std::this_thread::yield();
|
|
start.store(true);
|
|
left.join(); right.join();
|
|
require(successes == 1 && failures == 1, "concurrent initialization admits exactly one destination owner");
|
|
|
|
const auto failedPath = root + "/failed.jsonl";
|
|
char *childArguments[] = {argv[0], const_cast<char *>("--write-failure"),
|
|
const_cast<char *>(failedPath.c_str()), nullptr};
|
|
pid_t child = 0;
|
|
require(posix_spawnp(&child, argv[0], nullptr, nullptr, childArguments, environ) == 0, "launch isolated write-failure subprocess");
|
|
int status = 0;
|
|
require(waitpid(child, &status, 0) == child && WIFEXITED(status) && WEXITSTATUS(status) == 0,
|
|
"resource-limit failure remains nonfatal and output stays disabled");
|
|
require([[NSData dataWithContentsOfFile:nativePath(failedPath)] length] == 1, "forced failure permits only its final partial line");
|
|
require([[NSFileManager defaultManager] removeItemAtPath:nativePath(root) error:nullptr], "remove isolated check files");
|
|
std::puts("JSONL playback progress reporting passed");
|
|
}
|
|
return 0;
|
|
}
|