Files
terebi/Checks/progress-reporting.mm
T

460 lines
25 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]), "HTTP(S) 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);
const std::string genericURL = "https://video.example/watch?label=\"quoted\"&id=123#part";
require(ProgressReporter::validMediaURL(genericURL) &&
ProgressReporter::validMediaURL("http://127.0.0.1:8080/video.mp4"),
"HTTP(S) page identities are accepted beyond YouTube");
for (const char *invalid : {"", "file:///tmp/video.mp4", "ftp://example.com/video",
"https://", "https://user:secret@example.com/video", "https://example.com:0/video",
"https://example.com:65536/video", "https://example.com/video\n"})
require(!ProgressReporter::validMediaURL(invalid), "unsafe page identities are rejected");
const auto genericPath = root + "/generic.jsonl";
{
ProgressReporter reporter(genericPath, [] { return 0.; });
reporter.begin(genericURL);
reporter.observe(sample(PLAYER_PLAYING, 25));
reporter.finish();
require(reporter.error().empty(), "generic page progress drains successfully");
}
rows = records(genericPath);
require(rows.count == 2, "generic page has initial and terminal snapshots");
expect(rows[0], @"progress", genericURL, 25, true);
expect(rows[1], @"closed", genericURL, 25, true);
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 retired = root + "/retired.jsonl", replacement = root + "/replacement.jsonl";
{
playback::ProgressReports reports;
reports.registerFile(firstURL, retired);
reports.begin(firstURL);
reports.observe(sample(PLAYER_PAUSED, 12));
bool accepted = true;
try { reports.registerFile(firstURL, replacement); } catch (...) { accepted = false; }
require(accepted, "a removed identity can register a fresh reporting session");
auto retiredRows = records(retired);
require(retiredRows.count == 2, "replacement drains the retired writer");
expect(retiredRows[1], @"closed", firstURL, 12, true);
NSData *retiredBytes = [NSData dataWithContentsOfFile:nativePath(retired)];
reports.observe(sample(PLAYER_PAUSED, 50));
reports.begin(firstURL);
reports.observe(sample(PLAYER_PAUSED, 24));
reports.finish();
require([[NSData dataWithContentsOfFile:nativePath(retired)] isEqual:retiredBytes],
"replacement never appends to or overwrites the retired report");
auto replacementRows = records(replacement);
require(replacementRows.count == 2, "fresh session starts only when its media begins");
expect(replacementRows[0], @"progress", firstURL, 24, true);
expect(replacementRows[1], @"closed", firstURL, 24, true);
bool refused = false;
try { reports.registerFile(secondURL, retired); } catch (...) { refused = true; }
require(refused, "retired report paths remain reserved");
}
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[] = {firstURL.c_str(), secondURL.c_str()};
const auto unused = root + "/unused.jsonl";
const char *conflictingPaths[] = {rollbackPath.c_str(), liveNext.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));
NSData *singleBytes = [NSData dataWithContentsOfFile:nativePath(liveSingle)];
reports.registerFile(firstURL, unused);
reports.begin(firstURL);
reports.observe(sample(PLAYER_PAUSED, 11));
reports.finish();
require([[NSData dataWithContentsOfFile:nativePath(liveSingle)] isEqual:singleBytes],
"re-adding the original standalone identity leaves its old report unchanged");
auto reboundRows = records(unused);
require(reboundRows.count == 2, "standalone identity can start a fresh queue report");
expect(reboundRows[0], @"progress", firstURL, 11, true);
expect(reboundRows[1], @"closed", firstURL, 11, true);
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;
}