From c0f83b399f3457e2f6084b508946febf925c11da Mon Sep 17 00:00:00 2001 From: Daniel Souza <78171631+dnsouzadev@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:39:41 -0300 Subject: [PATCH] Fix Riot session aborts, CDN downloads and hidden client recovery --- .github/workflows/source-check.yml | 4 + docs/SETUP.md | 14 ++ docs/TESTING.md | 48 +++++ docs/TROUBLESHOOTING.md | 67 +++++++ patches/Libinfo.patch | 32 +++- patches/cfnetwork.patch | 275 +++++++++++++++++++++++++++ scripts/desktop-run.sh | 11 ++ tests/test_desktop_run.py | 36 ++++ tests/test_hidden_riot_recovery.py | 116 +++++++++++ tests/test_urlsession_runner.py | 55 ++++++ tools/build-riot-urlsession-check.py | 32 ++++ tools/recover-hidden-riot-client.py | 57 ++++++ tools/riot-cdn-dns-offline-check.py | 51 +++++ tools/riot-desktop-entry.py | 2 +- tools/riot-urlsession-check.m | 67 +++++++ tools/riot-urlsession-check.py | 86 +++++++++ tools/run-riot-client.sh | 6 + 17 files changed, 948 insertions(+), 11 deletions(-) create mode 100755 scripts/desktop-run.sh create mode 100644 tests/test_desktop_run.py create mode 100644 tests/test_hidden_riot_recovery.py create mode 100644 tests/test_urlsession_runner.py create mode 100644 tools/build-riot-urlsession-check.py create mode 100644 tools/recover-hidden-riot-client.py create mode 100644 tools/riot-cdn-dns-offline-check.py create mode 100644 tools/riot-urlsession-check.m create mode 100644 tools/riot-urlsession-check.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index a9f4428..1d02ffc 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -13,3 +13,7 @@ jobs: - run: python scripts/validate.py - run: python tools/riot-input-offline-check.py - run: python tools/riot-runtime-cleanup-offline-check.py + - run: python tools/riot-cdn-dns-offline-check.py + - run: python -m unittest discover -s tests -p 'test_hidden_riot_recovery.py' + - run: python -m unittest discover -s tests -p 'test_desktop_run.py' + - run: python -m unittest discover -s tests -p 'test_urlsession_runner.py' diff --git a/docs/SETUP.md b/docs/SETUP.md index d427c62..2bc1d15 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -59,6 +59,20 @@ Run configuration, builds and the per-user runtime installation as your ordinary The normal launcher starts neither an Electron inspector nor client API/UI helpers. It clears inherited `DYLD_INSERT_LIBRARIES`, retains Darling runtime library/framework overrides, supplies an absolute log directory to the launcher, and selects the League product. The log-directory/Chromium argument shim, empty-autofill hook and optional native VPX bridge are no longer injected or built by runtime installation. Existing copies on disk are inactive and may remain. Child-process log-directory rewriting and Chromium feature overrides previously supplied by the shim are removed; client startup without those workarounds needs manual validation. The first-time installer uses `--skip-to-install` and omits the installed client's `--app-root`, avoiding an immediate exit or inaccessible intro screen. The project ships no inspector hooks, client API helpers, input automation or client binary patches. Keep account data, logs and captures private. +## Application-menu diagnostics and recovery options + +`scripts/create-desktop-entry.sh` creates an application-menu entry that runs +`scripts/desktop-run.sh`, preserving the normal launcher behavior and saving +stdout/stderr in `build/desktop-launch.log` with mode 0600. Logs can contain +private session details; do not attach raw launcher logs to a public issue. +Region/locale remain the existing `RCLOL_REGION`/`RCLOL_LOCALE` options. + +`RCLOL_CDN_IPV4_ONLY` defaults to `1` in the Riot launcher and can be set to `0` +to disable the CDN-only resolver workaround. `RCLOL_RECOVER_HIDDEN_CLIENT=1` +opts in to Hyprland-only recovery of a default-prefix Riot session with no +visible window; it is off by default. See [troubleshooting](TROUBLESHOOTING.md#riot-exits-and-league-reports-that-it-lost-its-connection) +for the observed failures, recovery guards and NSURLSession limitations. + ## Updating an existing runtime `./scripts/update.sh` updates everything after `git pull`: `build.sh`, then the base install, then `install-runtime.sh`. When a pull changes a patch, `prepare.py` finds the earlier version applied in that component (its copy in `build/applied-patches`, or a version from this repository's history), reverts it, and applies the new one. It never resets a repository; if neither version matches, it stops and names the repository with other edits. The steps below update single components instead. diff --git a/docs/TESTING.md b/docs/TESTING.md index 49c5d0c..7ff2ef6 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -29,6 +29,54 @@ The LLVM version check compiles Iridium's actual dynamic loader against small st The patch check uses temporary base indexes and does not reset or modify the source checkout. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, and preserves Darling runtime library/framework paths. The memory-installer check imports the installer and exercises its policy (default source is the prepared artifact; the artifact is never a destination of a different library) in temporary directories. The Foundation-installer check does the same for the overlay installer: it seeds a temporary framework from a stub SDK, replaces `Versions/C/Foundation`, keeps the standard symlinks, stages a backup, treats a re-run as current and refuses a non-Mach-O source. The installer handoff mock covers 28 scenarios, including HTTP 409, interrupted verification and preserving active downloads. +## NSURLSession lifecycle regression + +After configuring and building the patched Darling source: + +```sh +python3 tools/build-riot-urlsession-check.py +python3 tools/riot-urlsession-check.py +``` + +The runner starts a loopback-only HTTP fixture, reserves a second non-listening +port for the transport-error case, and creates a fresh private DPREFIX. It runs +only the independent probe, then shuts down that private prefix. It never +starts Riot/League, queries their APIs, sends game input or shuts down the +normal prefix. The fixture delays its response so session invalidation is +tested with a request still outstanding. Run native guest probes with clients +and the game closed; source-only CI does not run them. + +The builder accepts `--build-dir` and `--sdk` for an existing configured tree. +The runner accepts `--frameworks` and `--libraries` host paths for installed +overlays, and keeps diagnostics in mode-0600 `build/urlsession-check.log`. + +Expected checks: five successful HTTP GETs with exact response bytes/status; +connection refusal delivered as an error; repeated resume/cancel without +duplicate completion; finish waiting for task completion; and session +invalidation delivered once for both graceful finish and cancellation. +Expected output includes `URLSESSION_PASS` and `LIFECYCLE_PASS`. The original +runtime aborts on missing data-task initialization or session invalidation. +These checks do not establish full NSURLSession conformance or transport-level +interruption of an already-running request. + +Host-only checks, which do not start Darling: + +```sh +python3 tools/riot-cdn-dns-offline-check.py +python3 -m unittest discover -s tests -p 'test_hidden_riot_recovery.py' +python3 -m unittest discover -s tests -p 'test_desktop_run.py' +python3 -m unittest discover -s tests -p 'test_urlsession_runner.py' +``` + +The DNS check compiles the actual `dr_query` addition from `Libinfo.patch` +against stub resolver functions. It covers default/opt-out behavior, case +insensitivity, A queries, non-CDN names and misleading domain suffixes without +sending a DNS query. Recovery tests use fake `/proc` entries and mock every +compositor/shutdown command. The desktop test checks exit/argument forwarding, +append-only output and tightening an existing public log to mode 0600. The +runner test uses a fake `darling` executable to verify loopback fixtures and +private-prefix-only shutdown/cleanup. + ## CoreText font-list and AppKit resize regressions After preparing the source and configuring the runtime tools: diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 0cc5bcf..7a2aa9c 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -74,6 +74,73 @@ The installer fetcher no longer treats a missing archive `Info.plist` or the inf There is also a separate known compatibility gap: native LaunchAgent watch registration can return HTTP 409 after the League install path is accepted, leaving the download idle. A historical inspector hook handles this and interrupted `pending_verify` states in 28 mock cases, but that hook is disabled in normal launches and excluded from the manual workflow. There is no verified runtime-only replacement for that handoff in this snapshot. Do not assume the native permission and launcher fixes resolve every idle Install case, or enable debugger/API automation to work around it. +## Riot exits and League reports that it lost its connection + +Check the launcher stderr or `build/desktop-launch.log` when launched through +the generated application-menu entry. The following Objective-C exceptions +were observed immediately before RiotClientServices aborted: + +``` +-[NSURLSessionDataTask initWithRequest:completionHandler:]: unrecognized selector +-[NSURLSession finishTasksAndInvalidate]: unrecognized selector +``` + +League then reports localhost connection refusal and loses its external-client +authorization. Restarting temporarily clears the broken session, but does not +implement the missing methods. The CFNetwork patch now supplies a minimal +completion-handler data-task implementation, uses the existing NSURLConnection +transport on a worker, and tracks outstanding tasks while a session finishes +or cancels. Session factories create the concrete CFNetwork session so its +delegate-queue storage is not confused with the public class's synthesized +properties. The URL convenience method also preserves GET rather than forcing POST. + +Rebuild and install the base and paired runtime with both clients and the game +closed. Run the standalone NSURLSession regression in [testing](TESTING.md#nsurlsession-lifecycle-regression). +This is partial NSURLSession compatibility: in-flight cancellation reports the +cancellation once but does not interrupt the synchronous transport already in +progress. Completion handlers use a global worker queue rather than the +session's delegate queue. Delegate-only data tasks, suspension, background +sessions, downloads and uploads are not established by these tests. + +### Client remains running without a window + +New launches can exit with `Client already running, exiting` when a stale Riot +session has no visible window. After exiting the game and clients, `darling +shutdown` followed by `scripts/run.sh` recovers this case without deleting game +files. For a dedicated Riot installation in the default prefix, Hyprland users +can opt in to automatic recovery: + +```sh +RCLOL_RECOVER_HIDDEN_CLIENT=1 ./scripts/run.sh +``` + +The helper requires an owned Riot process in the default prefix and a successful +Hyprland window query. It preserves a running game, any visible Riot/League +window, foreign-owned processes and custom prefixes, and takes no action when +ownership or window information cannot be read. A hidden background client or +download is restarted when recovery is enabled. Other desktop environments are +not supported by this helper; recovery is off by default. + +### Downloads repeatedly fail with an IPv6 CONNECT_TO error + +The bundled downloader was observed retrying CDN chunks with: + +``` +Use of IPv6 in *_CONNECT_TO without IPv6 support built-in +``` + +The launcher now defaults `RCLOL_CDN_IPV4_ONLY=1`. The Libinfo resolver suppresses +AAAA queries only for subdomains of `riotcdn.net`, retaining IPv4 DNS rotation, +TLS and the normal resolver for all other names. It does not pin CDN addresses +or disable IPv6 on the Linux host. Opt out with: + +```sh +RCLOL_CDN_IPV4_ONLY=0 ./scripts/run.sh +``` + +The resolver change requires a rebuilt/installed base runtime. The environment +flag alone cannot alter an older Libinfo binary. + ## Riot closes after a network lookup is cancelled Two local crashes were traced to `CFNetwork::_HostCancel` calling `CFRetain(NULL)`. diff --git a/patches/Libinfo.patch b/patches/Libinfo.patch index 71e83dd..72f97fe 100644 --- a/patches/Libinfo.patch +++ b/patches/Libinfo.patch @@ -1,11 +1,15 @@ diff --git a/darling-resolver/resolver.c b/darling-resolver/resolver.c -index f3ae33d..75944eb 100644 +index f3ae33d..6fb6e9e 100644 --- a/darling-resolver/resolver.c +++ b/darling-resolver/resolver.c -@@ -23,6 +23,21 @@ +@@ -22,6 +22,33 @@ + #include #include #include - ++#include ++#include ++#include ++ +/* res_query shares the global resolver's sockets. Concurrent lookups must + * own their resolver state so they cannot consume each other's replies. */ +static int @@ -13,6 +17,15 @@ index f3ae33d..75944eb 100644 +{ + struct __res_state state = {0}; + int result; ++ /* Riot's bundled downloader cannot use IPv6 CONNECT_TO addresses. ++ * Restrict this opt-in workaround to its CDN; keep other DNS unchanged. */ ++ const char *ipv4_only = getenv("RCLOL_CDN_IPV4_ONLY"); ++ size_t name_len = strlen(name); ++ const char suffix[] = ".riotcdn.net"; ++ if (type == ns_t_aaaa && ipv4_only && strcmp(ipv4_only, "1") == 0 && ++ name_len >= sizeof(suffix) - 1 && ++ strcasecmp(name + name_len - (sizeof(suffix) - 1), suffix) == 0) ++ return -1; + + if (res_ninit(&state) < 0) + return -1; @@ -20,11 +33,10 @@ index f3ae33d..75944eb 100644 + res_ndestroy(&state); + return result; +} -+ + static int dr_is_valid(si_mod_t *si, si_item_t *item) - { -@@ -58,7 +73,7 @@ dr_hostbyname(si_mod_t *si, const char *name, int af, const char *interface, uin +@@ -58,7 +85,7 @@ dr_hostbyname(si_mod_t *si, const char *name, int af, const char *interface, uin else return NULL; @@ -33,7 +45,7 @@ index f3ae33d..75944eb 100644 if (l < 0) return NULL; -@@ -144,7 +159,7 @@ dr_hostbyaddr(si_mod_t *si, const void *addr, int af, const char *interface, uin +@@ -144,7 +171,7 @@ dr_hostbyaddr(si_mod_t *si, const void *addr, int af, const char *interface, uin else return NULL; @@ -42,7 +54,7 @@ index f3ae33d..75944eb 100644 if (l < 0) return NULL; -@@ -240,7 +255,7 @@ dr_addrinfo(si_mod_t *si, const void *node, const void *serv, uint32_t family, u +@@ -240,7 +267,7 @@ dr_addrinfo(si_mod_t *si, const void *node, const void *serv, uint32_t family, u if (resolveV6) { @@ -51,7 +63,7 @@ index f3ae33d..75944eb 100644 if (l < 0) goto after_v6; -@@ -279,7 +294,7 @@ dr_addrinfo(si_mod_t *si, const void *node, const void *serv, uint32_t family, u +@@ -279,7 +306,7 @@ dr_addrinfo(si_mod_t *si, const void *node, const void *serv, uint32_t family, u after_v6: if (resolveV4) { @@ -60,7 +72,7 @@ index f3ae33d..75944eb 100644 if (l < 0) goto after_v4; -@@ -375,4 +390,3 @@ si_mod_t *si_module_static_darling(void) +@@ -375,4 +402,3 @@ si_mod_t *si_module_static_darling(void) return (si_mod_t*)&si; } diff --git a/patches/cfnetwork.patch b/patches/cfnetwork.patch index 4654032..4085574 100644 --- a/patches/cfnetwork.patch +++ b/patches/cfnetwork.patch @@ -1,3 +1,25 @@ +diff --git a/include/CFNetwork/NSCFURLSession.h b/include/CFNetwork/NSCFURLSession.h +index e2326a3..b7489da 100644 +--- a/include/CFNetwork/NSCFURLSession.h ++++ b/include/CFNetwork/NSCFURLSession.h +@@ -2,7 +2,7 @@ + #import + #import + +-@class NSOperationQueue, NSString, __NSCFSessionBridge; ++@class NSOperationQueue, NSString, NSMutableSet, __NSCFSessionBridge; + + __attribute__((visibility("hidden"))) + @interface __NSCFURLSessionConfiguration : NSObject +@@ -19,6 +19,8 @@ __attribute__((visibility("hidden"))) + __NSCFSessionBridge *_connectionSession; + __NSCFURLSession *_extraRetain; + NSOperationQueue *_realDelegateQueue; ++ NSMutableSet *_activeDataTasks; ++ BOOL _invalidationNotified; + } + + @property(retain) NSOperationQueue *realDelegateQueue; // @synthesize realDelegateQueue=_realDelegateQueue; diff --git a/src/Host/CFHost.c b/src/Host/CFHost.c index 91c0d0f..d6e3daf 100644 --- a/src/Host/CFHost.c @@ -15,3 +37,256 @@ index 91c0d0f..d6e3daf 100644 NULL, // copyDescription NULL, // equal NULL, // hash +diff --git a/src/URL/NSCFURLSession.m b/src/URL/NSCFURLSession.m +index 95a726b..f5ff824 100644 +--- a/src/URL/NSCFURLSession.m ++++ b/src/URL/NSCFURLSession.m +@@ -9,6 +9,8 @@ + + #import + #import ++#import ++#import + + @implementation __NSCFURLSessionConfiguration + @end +@@ -20,19 +22,19 @@ + static dispatch_once_t once = 0L; + static NSURLSession *sharedSession = nil; + dispatch_once(&once, ^{ +- sharedSession = (NSURLSession *)[[__NSCFURLSession alloc] initWithConfiguration:(NSURLSessionConfiguration *)[__NSCFURLSessionConfiguration defaultSessionConfiguration] delegate:nil delegateQueue:nil]; ++ sharedSession = (NSURLSession *)[[__NSCFURLSession alloc] initWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:nil delegateQueue:nil]; + }); + return sharedSession; + } + + + (NSURLSession *)sessionWithConfiguration:(NSURLSessionConfiguration *)configuration + { +- return (NSURLSession *)[[[self alloc] initWithConfiguration:configuration delegate:nil delegateQueue:nil] autorelease]; ++ return (NSURLSession *)[[[__NSCFURLSession alloc] initWithConfiguration:configuration delegate:nil delegateQueue:nil] autorelease]; + } + + + (NSURLSession *)sessionWithConfiguration:(NSURLSessionConfiguration *)configuration delegate:(id )delegate delegateQueue:(NSOperationQueue *)queue + { +- return (NSURLSession *)[[[self alloc] initWithConfiguration:configuration delegate:delegate delegateQueue:queue] autorelease]; ++ return (NSURLSession *)[[[__NSCFURLSession alloc] initWithConfiguration:configuration delegate:delegate delegateQueue:queue] autorelease]; + } + + + (void)_releaseProcessAssertionForSessionIdentifier:(NSString *)identifier +@@ -56,12 +58,62 @@ + return nil; + } + _nsCFConfig = (__NSCFURLSessionConfiguration *)[configuration retain]; +- _delegateQueue = [queue retain]; +- _delegate = delegate; ++ _delegateQueue = queue ? [queue retain] : [[NSOperationQueue alloc] init]; ++ if (!queue) [_delegateQueue setMaxConcurrentOperationCount:1]; ++ _delegate = [delegate retain]; ++ _activeDataTasks = [[NSMutableSet alloc] init]; + } + return self; + } + ++- (void)_notifyInvalidationIfReady ++{ ++ @synchronized(self) { ++ if (!_invalid || [_activeDataTasks count] || _invalidationNotified) return; ++ _invalidationNotified = YES; ++ } ++ [self.delegateQueue addOperationWithBlock:^{ ++ if ([self.delegate respondsToSelector:@selector(URLSession:didBecomeInvalidWithError:)]) ++ [self.delegate URLSession:(NSURLSession *)self didBecomeInvalidWithError:nil]; ++ [_delegate release]; ++ _delegate = nil; ++ }]; ++} ++ ++- (void)_taskDidComplete:(NSURLSessionTask *)task ++{ ++ @synchronized(self) { [_activeDataTasks removeObject:task]; } ++ [self _notifyInvalidationIfReady]; ++} ++ ++- (void)finishTasksAndInvalidate ++{ ++ @synchronized(self) { _invalid = YES; } ++ [self _notifyInvalidationIfReady]; ++} ++ ++- (void)invalidateAndCancel ++{ ++ NSArray *tasks; ++ @synchronized(self) { ++ _invalid = YES; ++ tasks = [[_activeDataTasks allObjects] copy]; ++ } ++ for (NSURLSessionTask *task in tasks) [task cancel]; ++ [tasks release]; ++ [self _notifyInvalidationIfReady]; ++} ++ ++- (void)dealloc ++{ ++ [_nsCFConfig release]; ++ [_delegateQueue release]; ++ [_delegate release]; ++ [_sessionDescription release]; ++ [_activeDataTasks release]; ++ [super dealloc]; ++} ++ + - (void)delegate_didFinishEventsForBackgroundURLSession + { + if ([self can_delegate_didFinishEventsForBackgroundURLSession]) +@@ -364,7 +416,13 @@ + + - (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSData *data, NSURLResponse *response, NSError *error))completionHandler + { +- return [[[NSURLSessionDataTask alloc] initWithRequest:request completionHandler:completionHandler] autorelease]; ++ @synchronized(self) { ++ if (_invalid) return nil; ++ NSURLSessionDataTask *task = [[[NSURLSessionDataTask alloc] initWithRequest:request completionHandler:completionHandler] autorelease]; ++ [task _setSession:self]; ++ [_activeDataTasks addObject:task]; ++ return task; ++ } + } + + - (NSURLSessionDataTask *)dataTaskWithURL:(NSURL *)url +@@ -374,9 +432,7 @@ + + - (NSURLSessionDataTask *)dataTaskWithURL:(NSURL *)url completionHandler:(void (^)(NSData *data, NSURLResponse *response, NSError *error))completionHandler + { +- NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; +- [request setHTTPMethod:@"POST"]; +- return [self dataTaskWithRequest:request completionHandler:completionHandler]; ++ return [self dataTaskWithRequest:[NSURLRequest requestWithURL:url] completionHandler:completionHandler]; + } + + - (NSURLSessionDataTask *)dataTaskWithHTTPGetRequest:(NSURL *)url +diff --git a/src/URL/NSURLSession.m b/src/URL/NSURLSession.m +index 39367a5..625e88d 100644 +--- a/src/URL/NSURLSession.m ++++ b/src/URL/NSURLSession.m +@@ -7,6 +7,11 @@ + + #import + #import ++#import ++#import ++#import ++#import ++#import + + const int64_t NSURLSessionTransferSizeUnknown = -1LL; + NSString* const NSURLSessionDownloadTaskResumeData = @"NSURLSessionDownloadTaskResumeData"; +@@ -28,7 +33,108 @@ NSString* const NSURLSessionDownloadTaskResumeData = @"NSURLSessionDownloadTaskR + @implementation NSURLSessionTask + @end + +-@implementation NSURLSessionDataTask ++@implementation NSURLSessionDataTask { ++ NSURLRequest *_taskRequest; ++ NSURLResponse *_taskResponse; ++ NSError *_taskError; ++ NSString *_taskDescription; ++ void (^_taskCompletion)(NSData *, NSURLResponse *, NSError *); ++ NSURLSessionTaskState _taskState; ++ NSUInteger _taskIdentifier; ++ int64_t _taskReceived; ++ id _taskSession; ++} ++ ++- (id)initWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSData *, NSURLResponse *, NSError *))handler ++{ ++ self = [super init]; ++ if (self) { ++ static NSUInteger nextIdentifier; ++ _taskIdentifier = __sync_add_and_fetch(&nextIdentifier, 1); ++ _taskRequest = [request copy]; ++ _taskCompletion = [handler copy]; ++ _taskState = NSURLSessionTaskStateSuspended; ++ } ++ return self; ++} ++ ++- (void)_finishWithData:(NSData *)data response:(NSURLResponse *)response error:(NSError *)error ++{ ++ void (^handler)(NSData *, NSURLResponse *, NSError *) = nil; ++ id session = nil; ++ @synchronized(self) { ++ if (_taskState == NSURLSessionTaskStateCompleted) ++ return; ++ _taskState = NSURLSessionTaskStateCompleted; ++ _taskResponse = [response retain]; ++ _taskError = [error retain]; ++ _taskReceived = [data length]; ++ handler = [_taskCompletion copy]; ++ [_taskCompletion release]; ++ _taskCompletion = nil; ++ session = _taskSession; ++ _taskSession = nil; ++ } ++ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ ++ @autoreleasepool { ++ if (handler) handler(data, response, error); ++ [session _taskDidComplete:self]; ++ } ++ }); ++ [handler release]; ++ [session release]; ++} ++ ++- (void)_setSession:(id)session { _taskSession = [session retain]; } ++ ++- (void)resume ++{ ++ @synchronized(self) { ++ if (_taskState != NSURLSessionTaskStateSuspended) ++ return; ++ _taskState = NSURLSessionTaskStateRunning; ++ } ++ // Use the existing CFNetwork transport off the caller's thread. The block ++ // keeps the task alive even if the caller releases it immediately. ++ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ ++ @autoreleasepool { ++ NSURLResponse *response = nil; ++ NSError *error = nil; ++ NSData *data = [NSURLConnection sendSynchronousRequest:_taskRequest returningResponse:&response error:&error]; ++ [self _finishWithData:data response:response error:error]; ++ } ++ }); ++} ++ ++- (void)cancel ++{ ++ [self _finishWithData:nil response:nil error:[NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorCancelled userInfo:nil]]; ++} ++ ++- (NSUInteger)taskIdentifier { return _taskIdentifier; } ++- (NSURLRequest *)originalRequest { return _taskRequest; } ++- (NSURLRequest *)currentRequest { return _taskRequest; } ++- (NSURLResponse *)response { @synchronized(self) { return _taskResponse; } } ++- (NSError *)error { @synchronized(self) { return _taskError; } } ++- (NSURLSessionTaskState)state { @synchronized(self) { return _taskState; } } ++- (int64_t)countOfBytesReceived { @synchronized(self) { return _taskReceived; } } ++- (NSString *)taskDescription { return _taskDescription; } ++- (void)setTaskDescription:(NSString *)value { ++ NSString *copy = [value copy]; ++ [_taskDescription release]; ++ _taskDescription = copy; ++} ++- (id)copyWithZone:(NSZone *)zone { return [self retain]; } ++- (void)dealloc ++{ ++ [_taskRequest release]; ++ [_taskResponse release]; ++ [_taskError release]; ++ [_taskDescription release]; ++ [_taskCompletion release]; ++ [_taskSession release]; ++ [super dealloc]; ++} + @end + + @implementation NSURLSessionDownloadTask diff --git a/scripts/desktop-run.sh b/scripts/desktop-run.sh new file mode 100755 index 0000000..66bb345 --- /dev/null +++ b/scripts/desktop-run.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Preserve diagnostics when a menu launcher has no terminal attached. +set -eu +umask 077 +repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +mkdir -p "$repo/build" +log="$repo/build/desktop-launch.log" +touch "$log" +chmod 600 "$log" +exec >>"$log" 2>&1 +exec "$repo/scripts/run.sh" "$@" diff --git a/tests/test_desktop_run.py b/tests/test_desktop_run.py new file mode 100644 index 0000000..4d2940c --- /dev/null +++ b/tests/test_desktop_run.py @@ -0,0 +1,36 @@ +"""Verify menu launch diagnostics without starting Darling or a client.""" +import os +from pathlib import Path +import shutil +import stat +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class DesktopRunTests(unittest.TestCase): + def test_private_log_and_arguments(self): + with tempfile.TemporaryDirectory(prefix='desktop runner ') as folder: + root = Path(folder) + (root / 'scripts').mkdir() + shutil.copy2(ROOT / 'scripts/desktop-run.sh', root / 'scripts/desktop-run.sh') + run = root / 'scripts/run.sh' + run.write_text('#!/bin/sh\nprintf "stdout:%s\\n" "$1"\nprintf "stderr\\n" >&2\nexit 7\n') + run.chmod(0o755) + (root / 'build').mkdir() + log = root / 'build/desktop-launch.log' + log.write_text('previous\n') + log.chmod(0o644) + result = subprocess.run(['sh', str(root / 'scripts/desktop-run.sh'), 'two words'], + capture_output=True, text=True) + self.assertEqual(result.returncode, 7) + self.assertEqual(result.stdout, '') + self.assertEqual(result.stderr, '') + self.assertEqual(stat.S_IMODE(log.stat().st_mode), 0o600) + self.assertEqual(log.read_text(), 'previous\nstdout:two words\nstderr\n') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_hidden_riot_recovery.py b/tests/test_hidden_riot_recovery.py new file mode 100644 index 0000000..eecf09e --- /dev/null +++ b/tests/test_hidden_riot_recovery.py @@ -0,0 +1,116 @@ +"""Exercise desktop recovery with fake /proc entries and mocked commands only.""" +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('recovery', ROOT / 'tools/recover-hidden-riot-client.py') +recovery = importlib.util.module_from_spec(spec) +spec.loader.exec_module(recovery) + + +class RecoveryTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.proc = self.root / 'proc' + self.proc.mkdir() + self.home = self.root / 'home' + self.home_patch = patch.object(Path, 'home', return_value=self.home) + self.home_patch.start() + self.addCleanup(self.home_patch.stop) + self.env_patch = patch.dict(os.environ, {'DPREFIX': str(self.home / '.darling')}) + self.env_patch.start() + self.addCleanup(self.env_patch.stop) + + def process(self, pid, executable, prefix=None): + folder = self.proc / str(pid) + folder.mkdir() + (folder / 'cmdline').write_bytes(executable.encode() + b'\0') + (folder / 'environ').write_bytes(b'' if prefix is None else b'DPREFIX=' + os.fsencode(prefix) + b'\0') + return folder + + def riot(self): + return self.process(123, '/Users/Shared/Riot Games/Riot Client.app/Contents/MacOS/RiotClientServices') + + def run_recovery(self, windows=None, error=None, raw=None): + def command(args, **kwargs): + if args[0] == 'hyprctl': + if error: + raise error + return subprocess.CompletedProcess(args, 0, stdout=raw if raw is not None else json.dumps(windows or [])) + return subprocess.CompletedProcess(args, 0) + with patch.object(recovery.subprocess, 'run', side_effect=command) as run: + result = recovery.recover(self.proc) + return result, [call.args[0] for call in run.call_args_list] + + def test_windowless_client_is_recovered(self): + self.riot() + result, calls = self.run_recovery() + self.assertTrue(result) + self.assertEqual(calls, [['hyprctl', 'clients', '-j'], ['darling', 'shutdown']]) + + def test_visible_client_is_preserved(self): + self.riot() + for identity in ('Riot Client', 'League of Legends'): + result, calls = self.run_recovery([{'class': identity, 'mapped': True, 'hidden': False}]) + self.assertFalse(result) + self.assertEqual(len(calls), 1) + + def test_game_is_preserved_even_without_windows(self): + self.riot() + self.process(124, '/Applications/LeagueofLegends.app/Contents/MacOS/LeagueofLegends') + self.assertEqual(self.run_recovery(), (False, [])) + + def test_no_riot_is_preserved(self): + self.process(124, '/usr/bin/editor') + self.assertEqual(self.run_recovery(), (False, [])) + + def test_foreign_owned_process_is_preserved(self): + self.riot() + with patch.object(recovery.os, 'getuid', return_value=os.getuid() + 1): + self.assertEqual(self.run_recovery(), (False, [])) + + def test_custom_prefix_is_preserved(self): + self.riot() + with patch.dict(os.environ, {'DPREFIX': str(self.root / 'other-prefix')}): + self.assertEqual(self.run_recovery(), (False, [])) + + def test_riot_in_another_prefix_is_preserved(self): + self.process(123, '/Applications/Riot Client.app/Contents/MacOS/RiotClientServices', self.root / 'other-prefix') + self.assertEqual(self.run_recovery(), (False, [])) + + def test_missing_or_failed_compositor_is_preserved(self): + self.riot() + for error in (FileNotFoundError(), subprocess.TimeoutExpired('hyprctl', 5), + subprocess.CalledProcessError(1, 'hyprctl')): + result, calls = self.run_recovery(error=error) + self.assertFalse(result) + self.assertEqual(len(calls), 1) + + def test_malformed_compositor_response_is_preserved(self): + self.riot() + for raw in ('invalid', '{}', '[null]'): + result, calls = self.run_recovery(raw=raw) + self.assertFalse(result) + self.assertEqual(len(calls), 1) + + def test_unreadable_environment_is_preserved(self): + self.riot() + original = Path.read_bytes + def read(path): + if path.name == 'environ': + raise PermissionError() + return original(path) + with patch.object(Path, 'read_bytes', read): + self.assertEqual(self.run_recovery(), (False, [])) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_urlsession_runner.py b/tests/test_urlsession_runner.py new file mode 100644 index 0000000..633eb88 --- /dev/null +++ b/tests/test_urlsession_runner.py @@ -0,0 +1,55 @@ +"""Check fixture traffic and private-prefix cleanup with a fake darling executable.""" +import json +import os +from pathlib import Path +import stat +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class URLSessionRunnerTests(unittest.TestCase): + def test_fixture_and_shutdown_use_only_a_private_prefix(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + executable = root / 'darling' + trace = root / 'trace.jsonl' + executable.write_text(f'#!{sys.executable}\n' + f'TRACE = {str(trace)!r}\n' + ''' +import json, os, sys, urllib.request +with open(TRACE, 'a') as stream: + stream.write(json.dumps({'args':sys.argv[1:], 'prefix':os.environ['DPREFIX']}) + '\\n') +if sys.argv[1] == 'shutdown': + raise SystemExit(0) +assert urllib.request.urlopen(sys.argv[2]).read() == b'darling-session-ok' +try: + urllib.request.urlopen(sys.argv[3], timeout=2) +except OSError: + pass +else: + raise AssertionError('reserved error endpoint unexpectedly accepted a connection') +print('LIFECYCLE_PASS: fixture') +print('URLSESSION_PASS: fixture') +''') + executable.chmod(0o755) + probe = root / 'probe' + probe.write_text('fake probe; never executed') + log = root / 'probe.log' + result = subprocess.run([sys.executable, str(ROOT / 'tools/riot-urlsession-check.py'), + '--probe', str(probe), '--log', str(log)], + env=dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH']), + capture_output=True, text=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + calls = [json.loads(line) for line in trace.read_text().splitlines()] + self.assertEqual(len(calls), 2) + self.assertEqual(calls[1]['args'], ['shutdown']) + self.assertEqual(calls[0]['prefix'], calls[1]['prefix']) + self.assertNotEqual(Path(calls[0]['prefix']), Path.home() / '.darling') + self.assertFalse(Path(calls[0]['prefix']).exists()) + self.assertEqual(stat.S_IMODE(log.stat().st_mode), 0o600) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/build-riot-urlsession-check.py b/tools/build-riot-urlsession-check.py new file mode 100644 index 0000000..2f98d4a --- /dev/null +++ b/tools/build-riot-urlsession-check.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Build the standalone NSURLSession probe against a configured Darling tree.""" +import argparse +import json +from pathlib import Path +import shlex +import subprocess + +root = Path(__file__).resolve().parents[1] +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--build-dir', type=Path, default=root / 'build/darling-src/build') +parser.add_argument('--sdk', type=Path) +args = parser.parse_args() +config_file = root / 'build/config.json' +config = json.loads(config_file.read_text()) if config_file.exists() else {} +sdk = args.sdk or Path(config.get('sdk', '/usr/local/libexec/darling')) +flags = {} +flags_file = args.build_dir / 'src/external/cfnetwork/src/CMakeFiles/CFNetwork.dir/flags.make' +for line in flags_file.read_text().splitlines(): + if line.startswith('C_') and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = shlex.split(value) +output = root / 'build' +output.mkdir(exist_ok=True) +obj = output / 'riot-urlsession-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-c', str(root / 'tools/riot-urlsession-check.m'), '-o', str(obj)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', str(sdk), str(obj), '-framework', 'Foundation', + '-framework', 'CFNetwork', '-lobjc', '-lSystem', + '-o', str(output / 'riot-urlsession-check')], check=True) +print('Built', output / 'riot-urlsession-check') diff --git a/tools/recover-hidden-riot-client.py b/tools/recover-hidden-riot-client.py new file mode 100644 index 0000000..58870ea --- /dev/null +++ b/tools/recover-hidden-riot-client.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Opt-in recovery of a windowless Riot session on Hyprland; fail closed elsewhere.""" +import json +import os +from pathlib import Path +import subprocess + + +def recover(proc_root=Path('/proc')): + home = Path.home() + prefix = Path(os.environ.get('DPREFIX', str(home / '.darling'))).expanduser() + if prefix != home / '.darling': + return False # Custom prefixes are not validated by this desktop helper. + riot_running = False + try: + for proc in proc_root.iterdir(): + if not proc.name.isdigit(): + continue + try: + if proc.stat().st_uid != os.getuid(): + continue + argv = (proc / 'cmdline').read_bytes().split(b'\0') + executable = argv[0].decode(errors='replace') + if executable.endswith('/LeagueofLegends'): + return False # Never restart a running game, even without a window. + if '/Riot Client.app/' not in executable: + continue + environment = dict(item.split(b'=', 1) for item in + (proc / 'environ').read_bytes().split(b'\0') if b'=' in item) + process_prefix = Path(os.fsdecode(environment.get(b'DPREFIX', os.fsencode(home / '.darling')))) + if process_prefix == prefix: + riot_running = True + except FileNotFoundError: + continue + except (OSError, ValueError): + return False # Unknown ownership/visibility must not trigger shutdown. + if not riot_running: + return False + try: + result = subprocess.run(['hyprctl', 'clients', '-j'], check=True, + capture_output=True, text=True, timeout=5) + windows = json.loads(result.stdout) + if not isinstance(windows, list): + return False + for window in windows: + identity = (window.get('class', '') + ' ' + window.get('title', '')).lower() + if any(name in identity for name in ('riot', 'league')) and window.get('mapped') and not window.get('hidden'): + return False + except (OSError, ValueError, TypeError, AttributeError, subprocess.SubprocessError): + return False + print('Recovering a Riot session with no visible client window.', flush=True) + subprocess.run(['darling', 'shutdown'], check=True, timeout=25) + return True + + +if __name__ == '__main__': + recover() diff --git a/tools/riot-cdn-dns-offline-check.py b/tools/riot-cdn-dns-offline-check.py new file mode 100644 index 0000000..41c687a --- /dev/null +++ b/tools/riot-cdn-dns-offline-check.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Compile the patch's actual DNS-query guard against resolver stubs; no DNS traffic.""" +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + +root = Path(__file__).resolve().parents[1] +patch = (root / 'patches/Libinfo.patch').read_text() +added = '\n'.join(line[1:] for line in patch.splitlines() if line.startswith('+') and not line.startswith('+++')) +match = re.search(r'static int\ndr_query\([^}]*\{.*?\n\}', added, re.S) +if not match: + raise SystemExit('dr_query implementation missing from patch') +source = r''' +#include +#include +#include +#include +#define ns_t_a 1 +#define ns_t_aaaa 28 +struct __res_state {int unused;}; +static int res_ninit(struct __res_state *s) {return 0;} +static int res_nquery(struct __res_state *s,const char *n,int c,int t,unsigned char *a,int l) {return 42;} +static void res_ndestroy(struct __res_state *s) {} +''' + match.group() + r''' +#define EXPECT(host,type,result) do { if(dr_query(host,1,type,0,0)!=(result)) {fprintf(stderr,"FAIL %s\n",host);return 1;} } while(0) +int main(void) { + unsetenv("RCLOL_CDN_IPV4_ONLY"); + EXPECT("lol.dyn.riotcdn.net",ns_t_aaaa,42); + setenv("RCLOL_CDN_IPV4_ONLY","1",1); + EXPECT("lol.dyn.riotcdn.net",ns_t_aaaa,-1); + EXPECT("LOL.SECURE.DYN.RIOTCDN.NET",ns_t_aaaa,-1); + EXPECT("lol.dyn.riotcdn.net",ns_t_a,42); + EXPECT("example.com",ns_t_aaaa,42); + EXPECT("riotcdn.net.example.com",ns_t_aaaa,42); + EXPECT("notriotcdn.net",ns_t_aaaa,42); + setenv("RCLOL_CDN_IPV4_ONLY","0",1); + EXPECT("lol.dyn.riotcdn.net",ns_t_aaaa,42); + puts("CDN_DNS_OFFLINE pass=1 opt_out=1 other_domains_preserved=1"); + return 0; +} +''' +compiler = shutil.which('cc') or shutil.which('clang') +if not compiler: + raise SystemExit('A native C compiler is required') +with tempfile.TemporaryDirectory(prefix='riot-cdn-dns-check-') as directory: + folder = Path(directory) + (folder / 'check.c').write_text(source) + subprocess.run([compiler, str(folder / 'check.c'), '-o', str(folder / 'check')], check=True) + subprocess.run([str(folder / 'check')], check=True) diff --git a/tools/riot-desktop-entry.py b/tools/riot-desktop-entry.py index 1626937..049e94d 100755 --- a/tools/riot-desktop-entry.py +++ b/tools/riot-desktop-entry.py @@ -140,7 +140,7 @@ def main(): shutil.copyfile(png_target, icons_base / "league-of-legends.png") # 3. Create .desktop file - run_script = PROJECT_ROOT / "scripts/run.sh" + run_script = PROJECT_ROOT / "scripts/desktop-run.sh" exec_path = str(run_script).replace("%", "%%") exec_cmd = f'"{exec_path}"' if " " in exec_path else exec_path desktop_content = f"""[Desktop Entry] diff --git a/tools/riot-urlsession-check.m b/tools/riot-urlsession-check.m new file mode 100644 index 0000000..ac80798 --- /dev/null +++ b/tools/riot-urlsession-check.m @@ -0,0 +1,67 @@ +// Standalone CFNetwork regression: only the host runner's loopback fixture is used. +#import +#import +#include +#include +static NSString *probeURL; +static NSString *failureURL; + +static int session_check(NSString *url, int cancelled) { + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block int ok = 0; + __block int callbacks = 0; + NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithURL:[NSURL URLWithString:url] completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + __sync_add_and_fetch(&callbacks, 1); + if (cancelled == 1) ok = error && [error code] == NSURLErrorCancelled; + else if (cancelled == 2) ok = error != nil; + else ok = !error && [data length] == 18 && memcmp([data bytes], "darling-session-ok", 18) == 0 && [(NSHTTPURLResponse *)response statusCode] == 200; + dispatch_semaphore_signal(sem); + }]; + if (!task || [task state] != NSURLSessionTaskStateSuspended) return 1; + if (cancelled == 1) { [task cancel]; [task cancel]; } + [task resume]; [task resume]; + if (dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 15 * NSEC_PER_SEC))) { printf("TIMEOUT\n"); return 2; } + if (!ok || callbacks != 1 || [task state] != NSURLSessionTaskStateCompleted) {printf("FAIL callback=%d ok=%d\n",callbacks,ok);return 3;} + return 0; +} +static dispatch_semaphore_t invalidated; +static volatile int invalidationCount; +@interface LifecycleProbe : NSObject +@end +@implementation LifecycleProbe +- (void)URLSession:(NSURLSession *)session didBecomeInvalidWithError:(NSError *)error { + __sync_add_and_fetch(&invalidationCount, 1); + dispatch_semaphore_signal(invalidated); +} +@end +static int lifecycle_check(int cancel) { + invalidated = dispatch_semaphore_create(0); + invalidationCount = 0; + dispatch_semaphore_t completed = dispatch_semaphore_create(0); + __block int ok = 0; + LifecycleProbe *delegate = [[LifecycleProbe alloc] init]; + NSURLSession *session = [[NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:delegate delegateQueue:nil] retain]; + NSURLSessionDataTask *task = [session dataTaskWithURL:[NSURL URLWithString:probeURL] completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + ok = cancel ? (error && [error code] == NSURLErrorCancelled) : (!error && [data length] == 18); + dispatch_semaphore_signal(completed); + }]; + if (cancel) { [session invalidateAndCancel]; [session invalidateAndCancel]; } + else { [task resume]; [session finishTasksAndInvalidate]; [session finishTasksAndInvalidate]; } + if (dispatch_semaphore_wait(completed, dispatch_time(DISPATCH_TIME_NOW, 15*NSEC_PER_SEC))) return 10; + if (dispatch_semaphore_wait(invalidated, dispatch_time(DISPATCH_TIME_NOW, 15*NSEC_PER_SEC))) return 11; + if (!ok || invalidationCount != 1) return 12; + [session release]; [delegate release]; + return 0; +} +int main(int argc, const char **argv) { @autoreleasepool { + if (argc != 3) { puts("Usage: probe "); return 64; } + probeURL = [NSString stringWithUTF8String:argv[1]]; + failureURL = [NSString stringWithUTF8String:argv[2]]; + for (int i=0;i<5;i++) if (session_check(probeURL,0)) return 1; + if (session_check(probeURL,1)) return 2; + if (session_check(failureURL,2)) return 3; + if (lifecycle_check(0) || lifecycle_check(1)) { puts("LIFECYCLE_FAIL"); return 4; } + puts("LIFECYCLE_PASS: finish waits for request; cancel delivers callback; invalidation once"); + puts("URLSESSION_PASS: 5 HTTP requests and cancel/resume, callbacks exactly once"); + return 0; +}} diff --git a/tools/riot-urlsession-check.py b/tools/riot-urlsession-check.py new file mode 100644 index 0000000..6738c94 --- /dev/null +++ b/tools/riot-urlsession-check.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Run only the standalone NSURLSession probe, against a loopback HTTP fixture. + +Uses a fresh private DPREFIX and never shuts down the user's normal prefix. +No Riot processes, account data, client APIs or client input are used. +""" +import argparse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import tempfile +from threading import Thread +import time + +root = Path(__file__).resolve().parents[1] +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--probe', type=Path, default=root / 'build/riot-urlsession-check') +parser.add_argument('--frameworks', type=Path, help='Host directory containing the installed framework overlays') +parser.add_argument('--libraries', type=Path, help='Host directory containing the installed dylib overlays') +parser.add_argument('--log', type=Path, default=root / 'build/urlsession-check.log') +args = parser.parse_args() +if not args.probe.is_file(): + parser.error('build the probe first') +config_file = root / 'build/config.json' +config = json.loads(config_file.read_text()) if config_file.exists() else {} +guest_user = config.get('guest_user', os.environ.get('USER', '')) +runtime = Path.home() / '.darling/Users' / guest_user +frameworks = args.frameworks or runtime / 'RuntimeFrameworks' +libraries = args.libraries or runtime / 'RuntimeLibraries' + + +class Fixture(BaseHTTPRequestHandler): + def do_GET(self): + time.sleep(0.05) # Ensure finishTasksAndInvalidate sees an in-flight task. + body = b'darling-session-ok' + self.send_response(200) + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *unused): + pass + + +server = ThreadingHTTPServer(('127.0.0.1', 0), Fixture) +reserved = socket.socket() +reserved.bind(('127.0.0.1', 0)) # Reserved but not listening: deterministic connection refusal. +Thread(target=server.serve_forever, daemon=True).start() +(root / 'build').mkdir(exist_ok=True) +prefix = Path(tempfile.mkdtemp(prefix='urlsession-prefix-', dir=root / 'build')) +env = {key: os.environ[key] for key in ('PATH', 'HOME', 'USER', 'LOGNAME', 'DISPLAY', + 'XAUTHORITY', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS', 'LANG') if key in os.environ} +env.update(DPREFIX=str(prefix), EGL_PLATFORM='x11', LC_ALL='en_US.UTF-8', + DYLD_FRAMEWORK_PATH='/Volumes/SystemRoot' + str(frameworks.resolve()), + DYLD_LIBRARY_PATH='/Volumes/SystemRoot' + str(libraries.resolve())) +log = args.log +result = None +try: + with log.open('w') as stream: + os.chmod(log, 0o600) + result = subprocess.run(['darling', str(args.probe.resolve()), + f'http://127.0.0.1:{server.server_port}/payload', + f'http://127.0.0.1:{reserved.getsockname()[1]}/payload'], + env=env, stdout=stream, stderr=subprocess.STDOUT, timeout=90) + text = log.read_text(errors='replace') + for line in text.splitlines(): + if line.startswith(('LIFECYCLE_PASS:', 'URLSESSION_PASS:', 'LIFECYCLE_FAIL', 'FAIL ', 'TIMEOUT')): + print(line) + if result.returncode or 'LIFECYCLE_PASS:' not in text or 'URLSESSION_PASS:' not in text: + raise SystemExit(f'Probe failed (exit {result.returncode}); inspect private log {log}') +finally: + server.shutdown() + server.server_close() + reserved.close() + try: + stopped = subprocess.run(['darling', 'shutdown'], env=env, capture_output=True, timeout=25) + if stopped.returncode == 0: + shutil.rmtree(prefix) + else: + print('Private test prefix retained for inspection:', prefix) + except (OSError, subprocess.SubprocessError): + print('Private test prefix retained for inspection:', prefix) diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 546bfea..75b6ec7 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -9,6 +9,12 @@ if [ "${RCLOL_CORE_DUMPS:-0}" != 1 ]; then fi runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) . "$runner_dir/runtime-paths.sh" +if [ "${RCLOL_RECOVER_HIDDEN_CLIENT:-0}" = 1 ]; then + python3 "$runner_dir/recover-hidden-riot-client.py" +fi +# The bundled downloader rejects IPv6 CONNECT_TO addresses. Only Riot CDN +# AAAA lookups are suppressed; set this to 0 to retain the upstream resolver. +export RCLOL_CDN_IPV4_ONLY="${RCLOL_CDN_IPV4_ONLY:-1}" python3 "$runner_dir/ensure-darling-runtime.py" python3 "$runner_dir/ensure-darling-service-accounts.py" python3 "$runner_dir/ensure-league-runtime-layout.py"