diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7dd60ff6..363a54ed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -79,3 +79,15 @@ jobs: sudo apt-get install -y clang FUZZ=1 ./scripts/test-update-flow-core.sh FUZZ=1 ./scripts/test-patch-core.sh + + ios-purge-restore-test: + name: Native purge-restore ordering + runs-on: macos-latest + timeout-minutes: 5 + + steps: + - uses: actions/checkout@v7 + # Foundation-only executable: exercises the production tvOS Release + # methods without RN, CocoaPods, a simulator, or real network delays. + - name: Run deterministic native ordering tests and negative controls + run: SANITIZE=1 VERIFY_REGRESSIONS=1 bash scripts/test-ios-purge-restore.sh diff --git a/scripts/test-ios-purge-restore.sh b/scripts/test-ios-purge-restore.sh new file mode 100755 index 00000000..87c036a4 --- /dev/null +++ b/scripts/test-ios-purge-restore.sh @@ -0,0 +1,113 @@ +#!/bin/sh +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)" +TEST_DIR="$ROOT_DIR/scripts/tests/ios-purge-restore" +BUILD_DIR="$ROOT_DIR/.tmp/ios-purge-restore-tests" + +python3 -m unittest discover -s "$TEST_DIR" -p test_extract.py +if [ "$(uname -s)" != "Darwin" ]; then + echo "Native purge-restore tests require macOS Foundation and Xcode command-line tools." >&2 + exit 1 +fi + +SANITIZE_FLAGS="" +if [ "${SANITIZE:-0}" = "1" ]; then + SANITIZE_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g" +fi + +# This is an orchestration test, not a stricter product warning gate. Keep +# warnings visible without promoting all of them to errors. Unknown Objective-C +# selectors are the one intentional error: they indicate an incomplete test seam. +WARNING_FLAGS="-Wall -Wextra -Wno-unused-parameter -Werror=objc-method-access" + +build() { + variant="$1" + destination="$BUILD_DIR/$variant" + mkdir -p "$destination" + if [ "$variant" = baseline ]; then + python3 "$TEST_DIR/extract.py" "$ROOT_DIR/ios/RCTPushy/RCTPushy.mm" "$destination" + else + python3 "$TEST_DIR/extract.py" "$ROOT_DIR/ios/RCTPushy/RCTPushy.mm" "$destination" --mutation "$variant" + fi + # Compile the real production bodies and state_core; only their external I/O + # collaborators live in the test host. No RN, CocoaPods, simulator or network. + xcrun clang++ -std=c++17 -fobjc-arc -fblocks $WARNING_FLAGS $SANITIZE_FLAGS \ + -I"$ROOT_DIR" -I"$destination" \ + "$TEST_DIR/purge_restore_test.mm" "$ROOT_DIR/cpp/patch_core/state_core.cpp" \ + -framework Foundation -o "$destination/purge_restore_test" +} + +build baseline +"$BUILD_DIR/baseline/purge_restore_test" "$@" + +# Negative controls execute only generated test copies. Every original bug must +# fail its named assertion, not merely fail to compile or time out. +if [ "${VERIFY_REGRESSIONS:-0}" = "1" ]; then + # Compile the same translation unit with narrowly selected contract probes. + # Unsupported selectors must fail for that selector, while a benign warning + # must compile. No probe modifies product code or the generated baseline. + compile_probe() { + xcrun clang++ -std=c++17 -fobjc-arc -fblocks $WARNING_FLAGS \ + -I"$ROOT_DIR" -I"$BUILD_DIR/baseline" -fsyntax-only -D"$1" \ + "$TEST_DIR/purge_restore_test.mm" + } + logfile="$BUILD_DIR/baseline/warning-probe.log" + if ! compile_probe TEST_HARMLESS_WARNING_PROBE >"$logfile" 2>&1; then + cat "$logfile" >&2 + exit 1 + fi + grep -F 'warning: PUSHY_TEST_HARMLESS_WARNING' "$logfile" + echo "[PASS] ordinary compiler warnings are non-fatal" + + for access in read write; do + case "$access" in + read) probe=TEST_DEFAULTS_READ_PROBE; selector='boolForKey:' ;; + write) probe=TEST_DEFAULTS_WRITE_PROBE; selector='setBool:forKey:' ;; + esac + logfile="$BUILD_DIR/baseline/defaults-$access-compile.log" + if compile_probe "$probe" >"$logfile" 2>&1; then + echo "ERROR: unsupported defaults $access unexpectedly compiled" >&2 + exit 1 + fi + if ! grep -F 'error:' "$logfile" | grep -F "no visible @interface for 'TestDefaults'" | grep -F "'$selector'"; then + cat "$logfile" >&2 + echo "ERROR: defaults $access failed compilation for an unexpected reason" >&2 + exit 1 + fi + testcase="unsupported_defaults_$access" + logfile="$BUILD_DIR/baseline/defaults-$access-runtime.log" + if "$BUILD_DIR/baseline/purge_restore_test" "$testcase" >"$logfile" 2>&1; then + echo "ERROR: unsupported defaults $access unexpectedly succeeded" >&2 + exit 1 + fi + if ! grep -Fx "[FAIL] $testcase: unsupported TestDefaults selector: $selector" "$logfile"; then + cat "$logfile" >&2 + echo "ERROR: defaults $access failed at runtime for an unexpected reason" >&2 + exit 1 + fi + echo "[PASS] unsupported defaults $access rejected at compile time and runtime" + done + + for pair in late-activation:late_commit skip-reresolve:commit_before_signal ignore-reset:reset_wins; do + variant="${pair%:*}" + testcase="${pair#*:}" + build "$variant" + logfile="$BUILD_DIR/$variant/result.log" + if "$BUILD_DIR/$variant/purge_restore_test" "$testcase" >"$logfile" 2>&1; then + echo "ERROR: $testcase did not detect $variant" >&2 + exit 1 + fi + case "$variant" in + late-activation) expected="late round may persist its response but must not activate" ;; + skip-reresolve) expected="must re-resolve B even when done signal is late" ;; + ignore-reset) expected="stale generation must reject ALL commit writes" ;; + esac + if ! grep -F "[FAIL] $testcase: $expected" "$logfile"; then + cat "$logfile" >&2 + echo "ERROR: $variant failed for an unexpected reason" >&2 + exit 1 + fi + echo "[PASS] negative control: $variant is detected by $testcase" + done +fi diff --git a/scripts/tests/ios-purge-restore/README.md b/scripts/tests/ios-purge-restore/README.md new file mode 100644 index 00000000..b3718f8e --- /dev/null +++ b/scripts/tests/ios-purge-restore/README.md @@ -0,0 +1,106 @@ +# Deterministic native purge-restore ordering tests + +Follow-up to #646. These tests exercise the tvOS Release branch of the real +`RCTPushy.mm` startup/commit/reset code in a macOS Foundation-only executable. +There are no production code changes, React Native mocks to install, CocoaPods, +simulator builds, network requests, or sleeps. + +## Run + +On macOS with Xcode command-line tools and Python 3.10+: + +```sh +bash scripts/test-ios-purge-restore.sh +SANITIZE=1 VERIFY_REGRESSIONS=1 bash scripts/test-ios-purge-restore.sh +# Run just one ordering: +bash scripts/test-ios-purge-restore.sh reset_wins +``` + +The `test` workflow runs the sanitized suite and negative controls on macOS. +The extractor's own tests also run independently on Linux/macOS: + +```sh +python3 -m unittest discover -s scripts/tests/ios-purge-restore -p test_extract.py +``` + +## Production code, not a second state machine + +`extract.py` reads the checkout's `ios/RCTPushy/RCTPushy.mm` on every build. It +copies the exact definitions (with `#line` locations) of `bundleURL`, +`resolveLaunchBundleURL`, `restorePurgedLaunch`, `commitRoundWithGeneration`, +`resetToPackagedBundle`, their state/defaults helpers, and the relevant globals +into build-only `.inc` files. These are compiled unchanged with the actual +`cpp/patch_core/state_core.cpp`. + +Extraction ignores braces in comments and literals, skips forward declarations, +and rejects missing or duplicate definitions. No generated implementation is +checked in. A source change that no longer fits the test host fails extraction +or compilation instead of silently testing a stale copy. + +The host replaces only collaborators: React Native export/logging plumbing, +application paths/configuration, defaults storage, network round delivery, +cold-start scheduling, the launch wait's return value/completion delivery, and +post-reset filesystem cleanup. Bundle-existence checks use real temporary files. +The production state lock is real; every defaults mutation asserts ownership of +that same `os_unfair_lock`. + +## Harness contracts + +This executable is not a stricter warning gate for the extracted product code. +`-Wall -Wextra` keeps warnings visible, without a blanket `-Werror`. The only +explicit warning promoted to an error is `objc-method-access`: an undeclared +selector means the test host no longer models a collaborator it needs. + +`TestDefaults` inherits from `NSObject`, **not** `NSUserDefaults`. Its six explicit +accessors use a private, per-instance dictionary. A test-only type substitution, +after importing Foundation, makes the extracted `NSUserDefaults *` declarations +refer to this narrow interface. A newly used accessor such as `setBool:forKey:` +or `boolForKey:` must be implemented deliberately; it cannot silently inherit a +path into the test process's real preferences. Dynamic calls that erase the +static type hit a fatal `doesNotRecognizeSelector:` backstop, which production +exception handlers cannot swallow. + +Every test process runs `defaults_isolation` before the ordering cases, verifying +fresh-instance isolation, the supported accessors and snapshot independence. +With `VERIFY_REGRESSIONS=1`, compile probes prove that an ordinary warning remains +non-fatal and both unsupported typed selectors are rejected. Separate subprocess +probes send the same messages through `id` and require the exact fail-fast runtime +diagnostic. No probe instantiates a real `NSUserDefaults` object or writes a real +preferences domain. + +## Orderings + +| Test | Enforced ordering | Assertions | +| --- | --- | --- | +| `late_commit` | Request captures generation; wait times out and closes window; only then release response/commit | Packaged bundle stays selected; round may cache its response/metadata for JS but cannot activate B or add `purgeRestore` | +| `commit_before_signal` | Commit activates B; completion signal is held; wait reports timeout; launch re-resolves; release signal | B actually launches; state/URL/running identity agree; first-load protection is armed and `purgeRestore` is recorded | +| `reset_wins` | Pause reset inside the real state lock; start stale commit; release reset | Old generation rejects **all** commit writes: no current/last version, version metadata or response cache; install UUID survives | +| `commit_wins` | Pause commit inside the real state lock; start reset; release commit | Earlier commit succeeds, then reset clears its state, metadata and cache; a delayed done signal cannot restore it | +| `complete_in_time` | Commit and done signal both arrive before wait returns | Normal successful restore still launches B with first-load protection | + +A real GCD worker captures the request generation and blocks at a controlled +response boundary. Semaphores establish each ordering. Reset races are exercised +in both legal lock orders, with one operation paused while it owns the lock and +the competing operation started on another thread. Five-second waits are only +fail-fast deadlock guards, never timing assumptions. The launch wait itself is +injected, so the tests do not spend 12 seconds per case. They still assert that +production requests a 12-second (not 13-second) wait budget. + +## Negative controls + +`VERIFY_REGRESSIONS=1` additionally compiles three generated-only mutants: + +- Remove the closed-window activation veto: `late_commit` must fail. +- Return `NO` after timeout instead of re-resolving: `commit_before_signal` must fail. +- Remove the reset-generation guard: `reset_wins` must fail. + +Each mutant must compile and fail its specific state assertion; unrelated +compiler errors, crashes or deadlock-guard failures do not count as detection. +The mutants never modify the checkout's production source. + +## Scope + +These are native orchestration regression tests, not tvOS device E2E tests. They +do not validate NSURLSession idle-timeout behavior, actual cache purging, update +download/unzip/diff pipelines, React Native bridge creation, or physical-device +watchdog limits. Existing E2E tests and device validation remain complementary. diff --git a/scripts/tests/ios-purge-restore/extract.py b/scripts/tests/ios-purge-restore/extract.py new file mode 100644 index 00000000..0d42ea8e --- /dev/null +++ b/scripts/tests/ios-purge-restore/extract.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Compile production Objective-C++ methods in a Foundation-only test host. + +No generated source is checked in. Bodies, signatures and source locations come +from RCTPushy.mm on every run. Extraction fails on missing/ambiguous definitions; +strings/comments cannot affect brace matching. Only the test host supplies I/O. +""" + +import argparse +import json +from pathlib import Path +import re + + +TOKENS = re.compile( + r'//[^\n]*|/\*[\s\S]*?\*/|' + r'(?:u8|u|U|L)?R"(?P[^\s()\\]{0,16})\([\s\S]*?\)(?P=delimiter)"|' + r'"(?:\\[\s\S]|[^"\\])*"|\'(?:\\[\s\S]|[^\'\\])*\'' +) + + +def mask_literals(source: str) -> str: + """Preserve offsets/newlines, hiding comments and string/character literals.""" + return TOKENS.sub(lambda m: re.sub(r'[^\n]', ' ', m.group()), source) + + +def definition(source: str, pattern: str, label: str) -> tuple[int, str]: + masked = mask_literals(source) + definitions = [] + for match in re.finditer(pattern, masked, re.MULTILINE): + opening = masked.find('{', match.end()) + semicolon = masked.find(';', match.end()) + if opening < 0 or 0 <= semicolon < opening: + continue # An interface declaration or a forward declaration. + depth = 1 + cursor = opening + 1 + while depth and cursor < len(masked): + depth += (masked[cursor] == '{') - (masked[cursor] == '}') + cursor += 1 + if depth: + raise ValueError(f'{label}: unterminated definition') + definitions.append((source.count('\n', 0, match.start()) + 1, + source[match.start():cursor])) + if len(definitions) != 1: + raise ValueError(f'{label}: expected one definition, found {len(definitions)}') + return definitions[0] + + +def function(source: str, name: str) -> tuple[int, str]: + return definition(source, rf'^static\b[^\n;]*?\b{re.escape(name)}\s*\(', name) + + +def method(source: str, name: str) -> tuple[int, str]: + return definition(source, rf'^\+\s*\([^\n)]*\)\s*{re.escape(name)}\b', name) + + +def exported_method(source: str, name: str) -> tuple[int, str]: + return definition(source, rf'^RCT_EXPORT_METHOD\(\s*{re.escape(name)}\s*:', name) + + +def declaration(source: str, name: str) -> tuple[int, str]: + matches = list(re.finditer(rf'^static\b[^\n;]*\b{re.escape(name)}\b[^\n;]*;', + mask_literals(source), re.MULTILINE)) + if len(matches) != 1: + raise ValueError(f'{name}: expected one static declaration, found {len(matches)}') + match = matches[0] + return (source.count('\n', 0, match.start()) + 1, + source[match.start():match.end()]) + + +GLOBALS = [ + 'keyPushyInfo', 'paramPackageVersion', 'paramBuildTime', + 'legacyParamPackageVersion', 'legacyParamBuildTime', 'paramLastVersion', + 'paramCurrentVersion', 'paramIsFirstTime', 'paramIsFirstLoadOk', 'keyUuid', + 'keyHashInfo', 'keyFirstLoadMarked', 'keyRolledBackMarked', + 'KeyPackageUpdatedMarked', 'keyNativeCheckCache', 'BUNDLE_FILE_NAME', + 'pushyStateLock', 'ignoreRollback', 'pushyIsUsingBundleUrl', + 'pushyResetGeneration', 'pushyLaunchVersion', 'pushyCrashRescueActive', + 'pushyPurgeRestoreActive', 'pushyPurgeRestoreWindowOpen', + 'pushyHostRoundResult', 'kPushyPurgeRestoreBudget', +] +HELPERS = [ + 'PushyWithStateLock', 'PushyToStdString', 'PushyFromStdString', + 'PushySetNullableString', 'PushyHashInfoKey', 'PushyBinaryIdentityValue', + 'PushyStateFromDefaults', 'PushyApplyStateToDefaults', 'PushySwitchVersionLocked', +] +MUTATIONS = ('late-activation', 'skip-reresolve', 'ignore-reset') + + +def mutate(text: str, mutation: str) -> str: + """Negative controls: prove each regression is detected, never ship a mutant.""" + patterns = { + 'late-activation': (r'\bactivation = nil;', '(void)activation;'), + 'skip-reresolve': (r'\breturn YES;', 'return !timedOut;'), + 'ignore-reset': ( + r'if \(pushyResetGeneration\.load\(\) != generation\)\s*\{\s*return;\s*\}', + '(void)generation;'), + } + pattern, replacement = patterns[mutation] + mutated, count = re.subn(pattern, replacement, text) + if count != 1: + raise ValueError(f'{mutation}: expected one mutation site, found {count}') + return mutated + + +def generate(source_path: Path, output: Path, mutation: str | None = None) -> None: + source = source_path.read_text(encoding='utf-8') + restore = method(source, 'restorePurgedLaunch') + commit = method(source, 'commitRoundWithGeneration') + if mutation == 'skip-reresolve': + restore = (restore[0], mutate(restore[1], mutation)) + elif mutation: + commit = (commit[0], mutate(commit[1], mutation)) + sections = { + 'globals.inc': [declaration(source, name) for name in GLOBALS], + 'helpers.inc': [function(source, name) for name in HELPERS], + 'pushy.inc': [method(source, 'bundleURL'), method(source, 'resolveLaunchBundleURL'), + exported_method(source, 'resetToPackagedBundle')], + 'orchestrator.inc': [restore, commit], + } + output.mkdir(parents=True, exist_ok=True) + filename = json.dumps(str(source_path.resolve())) + for name, snippets in sections.items(): + output.joinpath(name).write_text(''.join( + f'#line {line} {filename}\n{text}\n\n' for line, text in snippets + ), encoding='utf-8') + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('source', type=Path) + parser.add_argument('output', type=Path) + parser.add_argument('--mutation', choices=MUTATIONS) + args = parser.parse_args() + try: + generate(args.source, args.output, args.mutation) + except (OSError, ValueError) as error: + parser.exit(1, f'Native test extraction failed: {error}\n') + + +if __name__ == '__main__': + main() diff --git a/scripts/tests/ios-purge-restore/purge_restore_test.mm b/scripts/tests/ios-purge-restore/purge_restore_test.mm new file mode 100644 index 00000000..01bdd804 --- /dev/null +++ b/scripts/tests/ios-purge-restore/purge_restore_test.mm @@ -0,0 +1,509 @@ +// Foundation-only host for the UNMODIFIED production methods extracted at build +// time. The state lock, defaults transitions, launch resolution, restore window, +// commit and reset bodies all come from RCTPushy.mm; see README.md for the seams. +#import +#import +#import +#include +#include +#include +#include +#include +#include "cpp/patch_core/state_core.h" +#include "cpp/patch_core/patch_core.h" +#include "cpp/patch_core/error_codes.h" + +#undef TARGET_OS_TV +#define TARGET_OS_TV 1 +#undef DEBUG +#define DEBUG 0 + +typedef void (^RCTPromiseResolveBlock)(id); +typedef void (^RCTPromiseRejectBlock)(NSString *, NSString *, NSError *); +#define RCT_EXPORT_METHOD(method) - (void)method +static void TestLog(NSString *, ...) {} +#define RCTLogInfo TestLog +#define RCTLogWarn TestLog +#define RCTLogError TestLog + +#include "globals.inc" + +static const char *testName = "setup"; +static void Expect(bool condition, const char *message) { + if (!condition) { + std::fprintf(stderr, "[FAIL] %s: %s\n", testName, message); + std::fflush(stderr); + std::_Exit(1); + } +} + +// These timeouts are deadlock guards, NOT scheduling delays. Every ordering is +// established by a semaphore handshake; there are no sleeps/network requests. +static void Signal(dispatch_semaphore_t gate) { dispatch_semaphore_signal(gate); } +static void Await(dispatch_semaphore_t gate, const char *description) { + Expect(dispatch_semaphore_wait(gate, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)) == 0, + description); +} + +enum class Actor { None, Commit, Reset }; +enum class Order { CloseFirst, CommitBeforeSignal, ResetFirst, CommitFirst, CompleteInTime }; +static thread_local Actor actor = Actor::None; +static std::atomic pausedActor{Actor::None}; +static std::atomic pauseNextWrite{false}; +static dispatch_semaphore_t writeLocked, releaseWrite; +static dispatch_semaphore_t requestStarted, allowCommit, commitAttempted, commitFinished; +static dispatch_semaphore_t signalAttempted, allowSignal, workerSettled; +static dispatch_semaphore_t resetAttempted, resetFinished; +static Order order; +static uint64_t requestGeneration; +static BOOL roundCommitted, roundActivated; +static BOOL resetResolved; +static int scheduleCount; +static int64_t waitBudget; +static NSString *root; + +// In-memory collaborator, deliberately NOT an NSUserDefaults subclass: inherited +// accessors could otherwise reach real preferences without ObserveWrite(). Only +// explicitly modelled selectors are supported. Every mutation still holds the +// REAL production lock; pausing one write fixes the competing operation's order. +@interface TestDefaults : NSObject { + NSMutableDictionary *_values; +} +- (void)setObject:(id)value forKey:(NSString *)key; +- (void)removeObjectForKey:(NSString *)key; +- (id)objectForKey:(NSString *)key; +- (NSString *)stringForKey:(NSString *)key; +- (NSDictionary *)dictionaryForKey:(NSString *)key; +- (NSDictionary *)dictionaryRepresentation; +@end + +static void ObserveWrite(void) { + os_unfair_lock_assert_owner(&pushyStateLock); + if (actor == pausedActor.load() && pauseNextWrite.exchange(false)) { + Signal(writeLocked); + Await(releaseWrite, "release a paused state write"); + } +} + +@implementation TestDefaults +- (instancetype)init { + self = [super init]; + if (self) { _values = [NSMutableDictionary new]; } + return self; +} +- (void)setObject:(id)value forKey:(NSString *)key { ObserveWrite(); _values[key] = value; } +- (void)removeObjectForKey:(NSString *)key { ObserveWrite(); [_values removeObjectForKey:key]; } +- (id)objectForKey:(NSString *)key { return _values[key]; } +- (NSString *)stringForKey:(NSString *)key { + id value = _values[key]; + return [value isKindOfClass:NSString.class] ? value : nil; +} +- (NSDictionary *)dictionaryForKey:(NSString *)key { + id value = _values[key]; + return [value isKindOfClass:NSDictionary.class] ? value : nil; +} +- (NSDictionary *)dictionaryRepresentation { return [_values copy]; } +- (void)doesNotRecognizeSelector:(SEL)selector { + // Fail even if product code catches Objective-C exceptions. Dynamic sends + // must not fall through to a real defaults domain or turn into a passed test. + std::string message = "unsupported TestDefaults selector: "; + message += NSStringFromSelector(selector).UTF8String; + Expect(false, message.c_str()); +} +@end + +// Type substitution is confined to this test translation unit, AFTER Foundation +// is imported. The extracted bodies stay unchanged, but a newly used defaults +// selector must be explicitly implemented here rather than inherited silently. +#define NSUserDefaults TestDefaults + +// Compile-only contract probes, enabled individually by the runner. Ordinary +// warnings must remain non-fatal; unmodelled selectors must fail compilation. +#if defined(TEST_DEFAULTS_READ_PROBE) +static BOOL DefaultsReadProbe(NSUserDefaults *defaults) { + return [defaults boolForKey:@"probe"]; +} +#elif defined(TEST_DEFAULTS_WRITE_PROBE) +static void DefaultsWriteProbe(NSUserDefaults *defaults) { + [defaults setBool:YES forKey:@"probe"]; +} +#elif defined(TEST_HARMLESS_WARNING_PROBE) +#warning PUSHY_TEST_HARMLESS_WARNING +#endif + +static TestDefaults *testDefaults; +static NSUserDefaults *PushyDefaults(void) { return testDefaults; } +static NSTimeInterval PushyMonotonicNow(void) { return 100; } +static NSError *PushyErrorWithCode(const char *code, NSString *message) { + return [NSError errorWithDomain:@(code) code:1 userInfo:@{NSLocalizedDescriptionKey: message}]; +} +static void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error) { + reject(error.domain, error.localizedDescription, error); +} + +// Filesystem cleanup is not under test. Keep the real declaration/return type; +// the real reset body (including its asynchronous completion) still executes. +namespace pushy { namespace patch { +Status CleanupOldEntries(const std::string&, const std::vector&, + int max_age_days, std::time_t) { + Expect(max_age_days == 0, "reset requests a full cleanup"); + return {true, ""}; +} +}} + +@interface RCTPushy : NSObject { + dispatch_queue_t _fileQueue; +} ++ (NSURL *)bundleURL; ++ (NSURL *)resolveLaunchBundleURL:(NSString **)rolledBack purgedVersion:(NSString **)purged; ++ (NSURL *)binaryBundleURL; ++ (NSString *)packageVersion; ++ (NSString *)buildTime; ++ (NSString *)downloadDir; +- (void)resetToPackagedBundle:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject; +@end + +@interface RCTPushyOrchestrator : NSObject ++ (void)prepareProcess:(NSString *)rolledBack; ++ (BOOL)hasRunnableConfig; ++ (void)scheduleFromColdStart:(NSString *)rolledBack; ++ (void)startRoundWithDeadline:(NSTimeInterval)deadline; ++ (BOOL)restorePurgedLaunch:(NSString *)purged rolledBack:(NSString *)rolledBack; ++ (BOOL)commitRoundWithGeneration:(uint64_t)generation + hashInfo:(NSDictionary *)hashInfo + activate:(NSString *)hash + responseText:(NSString *)response + request:(NSString *)request + config:(NSString *)config + responseAt:(long long)responseAt + activated:(BOOL *)activated; +@end + +#include "helpers.inc" + +static RCTPushy *engine; + +static void StartReset(void) { + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + @autoreleasepool { + actor = Actor::Reset; + Signal(resetAttempted); + [engine resetToPackagedBundle:^(id result) { + resetResolved = [result boolValue]; + Signal(resetFinished); + } rejecter:^(NSString *, NSString *message, NSError *) { + std::fprintf(stderr, "reset rejected: %s\n", message.UTF8String); + Expect(false, "reset must resolve successfully"); + }]; + actor = Actor::None; + } + }); +} + +// Only the restore method's launch wait is replaced. Its dispatch_async remains +// real: the worker captures its generation, blocks at the response boundary, and +// eventually calls the real commit method on a DIFFERENT thread. +static long TestLaunchWait(dispatch_semaphore_t done, dispatch_time_t) { + Await(requestStarted, "round captured its generation before the launch wait"); + switch (order) { + case Order::CloseFirst: + return 1; // timeout while the response is still held + case Order::CommitBeforeSignal: + Signal(allowCommit); + break; + case Order::ResetFirst: + pausedActor.store(Actor::Reset); + pauseNextWrite.store(true); + StartReset(); + Await(writeLocked, "reset owns the state lock"); + Signal(allowCommit); + Await(commitAttempted, "stale commit starts while reset holds the lock"); + Signal(releaseWrite); + Await(resetFinished, "reset finished"); + break; + case Order::CommitFirst: + pausedActor.store(Actor::Commit); + pauseNextWrite.store(true); + Signal(allowCommit); + Await(writeLocked, "commit owns the state lock"); + StartReset(); + Await(resetAttempted, "reset starts while commit holds the lock"); + Signal(releaseWrite); + Await(resetFinished, "reset finished after the commit"); + break; + case Order::CompleteInTime: + Signal(allowCommit); + Signal(allowSignal); + Await(done, "restore completion delivered before timeout"); + return 0; + } + Await(commitFinished, "commit finished before synthetic timeout"); + Await(signalAttempted, "worker reached, but has not delivered, the done signal"); + return 1; +} + +static long TestCompletionSignal(dispatch_semaphore_t done) { + Signal(signalAttempted); + Await(allowSignal, "release the deliberately delayed completion signal"); + long result = dispatch_semaphore_signal(done); + Signal(workerSettled); + return result; +} + +static dispatch_time_t TestLaunchDeadline(dispatch_time_t when, int64_t delta) { + Expect(when == DISPATCH_TIME_NOW, "launch wait uses a relative budget"); + waitBudget = delta; + return dispatch_time(when, delta); +} + +@implementation RCTPushy +- (instancetype)init { + self = [super init]; + if (self) { _fileQueue = dispatch_queue_create("pushy.test.files", DISPATCH_QUEUE_SERIAL); } + return self; +} ++ (NSURL *)binaryBundleURL { return [NSURL fileURLWithPath:[root stringByAppendingPathComponent:@"packaged.bundle"]]; } ++ (NSString *)packageVersion { return @"1.0"; } ++ (NSString *)buildTime { return @"123"; } ++ (NSString *)downloadDir { return [root stringByAppendingPathComponent:@"updates"]; } +#include "pushy.inc" +@end + +@implementation RCTPushyOrchestrator ++ (void)prepareProcess:(NSString *)rolledBack { (void)rolledBack; } ++ (BOOL)hasRunnableConfig { return YES; } ++ (void)scheduleFromColdStart:(NSString *)rolledBack { (void)rolledBack; ++scheduleCount; } ++ (void)startRoundWithDeadline:(NSTimeInterval)deadline { + @autoreleasepool { + actor = Actor::Commit; + Expect(deadline == 100 + kPushyPurgeRestoreBudget, "round receives the launch deadline"); + requestGeneration = pushyResetGeneration.load(); + Signal(requestStarted); + Await(allowCommit, "release the native check response"); + Signal(commitAttempted); + // B represents an already-complete on-disk download. Do not reproduce + // the activation policy here: the production commit must enforce it. + roundCommitted = [self commitRoundWithGeneration:requestGeneration + hashInfo:@{@"hash": @"B", @"info": @{@"name": @"B", @"forceBootRescue": @YES}} + activate:@"B" responseText:@"{\"update\":true,\"hash\":\"B\"}" + request:@"request" config:@"config" responseAt:123 activated:&roundActivated]; + pushyHostRoundResult = @{@"status": @"test", @"reason": @"", @"hash": @"B"}; + Signal(commitFinished); + actor = Actor::None; + } +} +#define dispatch_semaphore_wait TestLaunchWait +#define dispatch_semaphore_signal TestCompletionSignal +#define dispatch_time TestLaunchDeadline +#include "orchestrator.inc" +#undef dispatch_time +#undef dispatch_semaphore_signal +#undef dispatch_semaphore_wait +@end + +static void SetUp(Order selected) { + order = selected; + actor = Actor::None; + pausedActor.store(Actor::None); + pauseNextWrite.store(false); + writeLocked = dispatch_semaphore_create(0); + releaseWrite = dispatch_semaphore_create(0); + requestStarted = dispatch_semaphore_create(0); + allowCommit = dispatch_semaphore_create(0); + commitAttempted = dispatch_semaphore_create(0); + commitFinished = dispatch_semaphore_create(0); + signalAttempted = dispatch_semaphore_create(0); + allowSignal = dispatch_semaphore_create(0); + workerSettled = dispatch_semaphore_create(0); + resetAttempted = dispatch_semaphore_create(0); + resetFinished = dispatch_semaphore_create(0); + roundCommitted = NO; + roundActivated = YES; // rejected commits must explicitly overwrite this + resetResolved = NO; + scheduleCount = 0; + waitBudget = 0; + testDefaults = [TestDefaults new]; + engine = [RCTPushy new]; + root = [NSTemporaryDirectory() stringByAppendingPathComponent:NSUUID.UUID.UUIDString]; + NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:@"B"]; + Expect([[NSFileManager defaultManager] createDirectoryAtPath:versionDir + withIntermediateDirectories:YES attributes:nil error:NULL], "create B fixture directory"); + Expect([@"B" writeToFile:[versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME] + atomically:YES encoding:NSUTF8StringEncoding error:NULL], "create B fixture bundle"); + Expect([@"packaged" writeToURL:[RCTPushy binaryBundleURL] + atomically:YES encoding:NSUTF8StringEncoding error:NULL], "create packaged fixture bundle"); + PushyWithStateLock(^{ + pushyResetGeneration.store(0); + ignoreRollback.store(false); + pushyIsUsingBundleUrl.store(false); + pushyLaunchVersion = nil; + pushyCrashRescueActive.store(false); + pushyPurgeRestoreActive.store(false); + pushyPurgeRestoreWindowOpen = false; + pushyHostRoundResult = nil; + pushy::state::State state; + state.package_version = "1.0"; + state.build_time = "123"; + state.current_version = "A"; // A is purged; B remains complete on disk + PushyApplyStateToDefaults(testDefaults, state); + [testDefaults setObject:@"installation-uuid" forKey:keyUuid]; + }); +} + +static pushy::state::State State(void) { + __block pushy::state::State state; + PushyWithStateLock(^{ state = PushyStateFromDefaults(testDefaults); }); + return state; +} + +static id Value(NSString *key) { + __block id value; + PushyWithStateLock(^{ value = [testDefaults objectForKey:key]; }); + return value; +} + +static NSDictionary *Info(void) { + NSString *json = Value(PushyHashInfoKey(@"B")); + return json == nil ? nil : [NSJSONSerialization JSONObjectWithData: + [json dataUsingEncoding:NSUTF8StringEncoding] options:0 error:NULL]; +} + +static void ExpectPackaged(NSURL *url) { + Expect([url isEqual:[RCTPushy binaryBundleURL]], "launch must use packaged bundle"); + auto state = State(); + Expect(state.current_version.empty(), "packaged launch must not select B"); + Expect(state.last_version.empty(), "packaged launch must have no stale last version"); + Expect(!state.first_time && state.first_time_ok, "packaged launch must not arm first load"); + Expect(state.rolled_back_version.empty(), "purge must not add a rollback mark"); + Expect(Value(keyFirstLoadMarked) == nil, "packaged launch must not report isFirstTime"); + Expect(pushyLaunchVersion == nil, "packaged launch must not claim B ran"); +} + +static void ExpectRestored(NSURL *url) { + Expect([url.path isEqualToString:[[[RCTPushy downloadDir] stringByAppendingPathComponent:@"B"] + stringByAppendingPathComponent:BUNDLE_FILE_NAME]], "must re-resolve B even when done signal is late"); + auto state = State(); + Expect(state.current_version == "B", "state must select the launched B"); + Expect(!state.first_time && !state.first_time_ok, "B first load must be consumed but not acknowledged"); + Expect([Value(keyFirstLoadMarked) boolValue], "B launch must report isFirstTime"); + Expect([pushyLaunchVersion isEqualToString:@"B"], "running bundle identity must be B"); + Expect([Info()[@"purgeRestore"] boolValue], "in-window activation must record purgeRestore"); + Expect([Info()[@"forceBootRescue"] boolValue], "other metadata must survive the commit"); + Expect(Value(keyNativeCheckCache) != nil, "successful round must cache its response"); +} + +static void Finish(void) { + if (order != Order::CompleteInTime) { Signal(allowSignal); } + Await(workerSettled, "worker's delayed signal delivered before teardown"); + Expect(waitBudget == (int64_t)(12 * NSEC_PER_SEC), "launch wait must stay at 12 seconds, not 13"); + Expect(scheduleCount == 1, "bundleURL must retain its finally scheduling path"); + Expect(!pushyPurgeRestoreWindowOpen, "every completed launch must close its restore window"); + Expect([[NSFileManager defaultManager] removeItemAtPath:root error:NULL], "remove fixture files"); +} + +static void LateCommit(void) { + SetUp(Order::CloseFirst); + NSURL *url = [RCTPushy bundleURL]; + ExpectPackaged(url); + Signal(allowCommit); + Await(commitFinished, "late response committed"); + Await(signalAttempted, "late round reached its done signal"); + Expect(roundCommitted && !roundActivated, "late round may persist its response but must not activate"); + ExpectPackaged(url); + Expect(Value(keyNativeCheckCache) != nil, "late response remains available to JS"); + Expect(Info() != nil && Info()[@"purgeRestore"] == nil, "non-activation must not claim purgeRestore"); + Finish(); +} + +static void CommitBeforeSignal(void) { + SetUp(Order::CommitBeforeSignal); + NSURL *url = [RCTPushy bundleURL]; + Expect(roundCommitted && roundActivated, "commit won the restore window"); + ExpectRestored(url); + Finish(); +} + +static void ResetOrdering(Order selected) { + SetUp(selected); + NSURL *url = [RCTPushy bundleURL]; + Expect(resetResolved, "reset promise must resolve after its file queue work"); + Expect(pushyResetGeneration.load() == 1 && requestGeneration == 0, "reset must invalidate the captured generation"); + if (selected == Order::ResetFirst) { + Expect(!roundCommitted && !roundActivated, "stale generation must reject ALL commit writes"); + } else { + Expect(roundCommitted && roundActivated, "earlier commit must succeed before reset clears it"); + } + ExpectPackaged(url); + Expect(Value(PushyHashInfoKey(@"B")) == nil, "reset must leave no version metadata"); + Expect(Value(keyNativeCheckCache) == nil, "reset must leave no response cache"); + Expect([Value(keyUuid) isEqual:@"installation-uuid"], "reset must preserve install identity"); + Finish(); +} + +static void CompleteInTime(void) { + SetUp(Order::CompleteInTime); + ExpectRestored([RCTPushy bundleURL]); + Expect(roundCommitted && roundActivated, "ordinary in-window restore must still activate"); + Finish(); +} + +// This contract test deliberately does not call SetUp: a new collaborator must +// start empty on its own, not only because the fixture reset some known keys. +static void DefaultsIsolation(void) { + TestDefaults *first = [TestDefaults new]; + TestDefaults *second = [TestDefaults new]; + Expect(![first isKindOfClass:NSClassFromString(@"NSUserDefaults")], + "test defaults must not inherit real preferences storage"); + PushyWithStateLock(^{ + [first setObject:@"value" forKey:@"key"]; + [first setObject:@{@"nested": @YES} forKey:@"dictionary"]; + Expect([[first stringForKey:@"key"] isEqual:@"value"], "string accessor uses memory"); + Expect([[first dictionaryForKey:@"dictionary"][@"nested"] boolValue], + "dictionary accessor uses memory"); + NSDictionary *snapshot = [first dictionaryRepresentation]; + [first removeObjectForKey:@"key"]; + Expect([first objectForKey:@"key"] == nil, "remove accessor uses memory"); + Expect([snapshot[@"key"] isEqual:@"value"], "dictionary snapshot is independent"); + Expect([second dictionaryRepresentation].count == 0, "instances must not share defaults"); + }); +} + +int main(int argc, char **argv) { + @autoreleasepool { + // These intentional failures run in separate processes. Erasing the + // static type also checks the runtime backstop, not just the compiler. + if (argc > 1 && (std::string(argv[1]) == "unsupported_defaults_read" || + std::string(argv[1]) == "unsupported_defaults_write")) { + testName = argv[1]; + id defaults = [TestDefaults new]; + if (std::string(argv[1]) == "unsupported_defaults_read") { + (void)[defaults boolForKey:@"probe"]; + } else { + PushyWithStateLock(^{ [defaults setBool:YES forKey:@"probe"]; }); + } + Expect(false, "unsupported defaults call unexpectedly returned"); + } + testName = "defaults_isolation"; + DefaultsIsolation(); + std::printf("[PASS] defaults_isolation\n"); + struct Case { const char *name; void (*run)(); }; + const Case cases[] = { + {"late_commit", LateCommit}, + {"commit_before_signal", CommitBeforeSignal}, + {"reset_wins", [] { ResetOrdering(Order::ResetFirst); }}, + {"commit_wins", [] { ResetOrdering(Order::CommitFirst); }}, + {"complete_in_time", CompleteInTime}, + }; + int ran = 0; + for (const auto &test : cases) { + if (argc > 1 && std::string(argv[1]) != test.name) { continue; } + testName = test.name; + test.run(); + ++ran; + std::printf("[PASS] %s\n", test.name); + } + Expect(ran > 0, "unknown test case"); + std::printf("%d native purge-restore ordering tests passed\n", ran); + } + return 0; +} diff --git a/scripts/tests/ios-purge-restore/test_extract.py b/scripts/tests/ios-purge-restore/test_extract.py new file mode 100644 index 00000000..0dd4c14d --- /dev/null +++ b/scripts/tests/ios-purge-restore/test_extract.py @@ -0,0 +1,56 @@ +import unittest + +from extract import declaration, exported_method, function, mask_literals, method, mutate + + +class ExtractionTests(unittest.TestCase): + def test_preserves_offsets_and_lines(self): + source = '/* {\n} */ @"escaped \\" { }"; // }\nconst char *x = R"tag({"})tag";' + masked = mask_literals(source) + self.assertEqual(len(masked), len(source)) + self.assertEqual(masked.count('\n'), source.count('\n')) + self.assertNotIn('{', masked) + self.assertNotIn('}', masked) + + def test_skips_prototype_and_keeps_body_verbatim(self): + body = 'static void run() {\n auto block = ^{ @"}"; /* } */ };\n}' + source = 'static void run();\n// static void run() {}\n' + body + line, actual = function(source, 'run') + self.assertEqual(line, 3) + self.assertEqual(actual, body) + + def test_objc_declaration_and_definition(self): + source = '+ (BOOL)restore:(id)value;\n+ (BOOL)restore:(id)value { return YES; }' + self.assertEqual(method(source, 'restore'), (2, source.splitlines()[1])) + + def test_objc_no_argument_method(self): + self.assertEqual(method('+ (id)bundleURL\n{ return nil; }', 'bundleURL')[0], 1) + + def test_exported_reset_method(self): + source = 'RCT_EXPORT_METHOD(reset:(id)resolve\n rejecter:(id)reject)\n{ ^{ @"}"; }; }' + self.assertEqual(exported_method(source, 'reset')[1], source) + + def test_static_initializers_and_string_contents(self): + self.assertEqual(declaration('static std::atomic active{false};', 'active')[1], + 'static std::atomic active{false};') + text = 'static NSString *const key = @"value;with;semicolons";' + self.assertEqual(declaration(text, 'key')[1], text) + + def test_missing_duplicate_or_unbalanced_source_fails_closed(self): + for source in ('static void run();', 'static void run() {', + 'static void run() {}\nstatic void run() {}'): + with self.subTest(source=source), self.assertRaises(ValueError): + function(source, 'run') + with self.assertRaises(ValueError): + declaration('static bool other = false;', 'active') + + def test_mutations_fail_on_source_drift(self): + self.assertEqual(mutate('activation = nil;', 'late-activation'), '(void)activation;') + self.assertEqual(mutate('return YES;', 'skip-reresolve'), 'return !timedOut;') + for source in ('', 'activation = nil; activation = nil;'): + with self.assertRaises(ValueError): + mutate(source, 'late-activation') + + +if __name__ == '__main__': + unittest.main()