Merge pull request #27 from dnsouzadev/fix/riot-session-crashes-download-recovery

Fix Riot session crashes, stalled downloads and hidden client launches
This commit is contained in:
Kalah authored and GitHub committed 2026-10-18 14:41:48 +02:00
commit 4d7049f194
18 files changed
+1101 -12

No files matched your search

+4
View File
@@ -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'
+14
View File
@@ -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.
+61
View File
@@ -29,6 +29,67 @@ 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, building and installing the patched Darling runtime (including
CFNetwork), run the probe. Supply `--frameworks` and `--libraries` when using
staged overlays instead of the installed runtime:
```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 reserves a private parent directory with
an initially absent DPREFIX. Darling must create DPREFIX itself to initialize
guest accounts and configuration; an existing empty directory skips that setup. 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; shared-session
invalidation leaving later requests usable; and completion delivery honoring a
suspended custom delegate queue.
Expected output includes `URLSESSION_PASS`, `LIFECYCLE_PASS`,
`SHARED_SESSION_PASS` and `QUEUE_PASS`. The original
runtime aborts on missing data-task initialization or session invalidation.
Native validation on 2026-10-18 passed all four markers using a staged, patched
CFNetwork framework and the installed Darling base runtime. No installed SDK
libraries were replaced. Darling shutdown may leave a root-owned overlay work
directory; the runner reports retained private artifacts if cleanup cannot
remove it.
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:
+72
View File
@@ -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)`.
@@ -350,3 +417,8 @@ If a black fullscreen game has only its normal Metal child and becomes visible a
### Visible cursor while relative on Xwayland (PR #18)
When Xwayland does not honour warps, the virtual cursor follows real pointer motion and application warps leave it in place. Expected warp echoes are still discarded using their request serial and coordinates. This keeps clicks and camera edge panning aligned with the visible cursor on GNOME/gamescope. The contributor confirmed this path in a gamescope practice-tool session; plain GNOME and NVIDIA still require manual verification.
Recovery requires every matching client to be at least 60 seconds old and a
complete Hyprland window record (`class`, `title`, `mapped`, `hidden`). A second
process scan after window inspection must find the same client identities and
no game. Missing information or shutdown failure leaves normal launch in charge.
+22 -10
View File
@@ -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 <stdio.h>
#include <stdbool.h>
#include <time.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+
+/* 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;
}
+288
View File
@@ -1,3 +1,26 @@
diff --git a/include/CFNetwork/NSCFURLSession.h b/include/CFNetwork/NSCFURLSession.h
index e2326a3..32efa89 100644
--- a/include/CFNetwork/NSCFURLSession.h
+++ b/include/CFNetwork/NSCFURLSession.h
@@ -2,7 +2,7 @@
#import <Foundation/NSURLSession.h>
#import <dispatch/dispatch.h>
-@class NSOperationQueue, NSString, __NSCFSessionBridge;
+@class NSOperationQueue, NSString, NSMutableSet, __NSCFSessionBridge;
__attribute__((visibility("hidden")))
@interface __NSCFURLSessionConfiguration : NSObject
@@ -19,6 +19,9 @@ __attribute__((visibility("hidden")))
__NSCFSessionBridge *_connectionSession;
__NSCFURLSession *_extraRetain;
NSOperationQueue *_realDelegateQueue;
+ NSMutableSet *_activeDataTasks;
+ BOOL _invalidationNotified;
+ BOOL _isSharedSession;
}
@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 +38,268 @@ index 91c0d0f..d6e3daf 100644
NULL, // copyDescription
NULL, // equal
NULL, // hash
diff --git a/src/URL/NSCFURLSession.m b/src/URL/NSCFURLSession.m
index 95a726b..86291e8 100644
--- a/src/URL/NSCFURLSession.m
+++ b/src/URL/NSCFURLSession.m
@@ -9,6 +9,8 @@
#import <Foundation/NSData.h>
#import <Foundation/NSOperation.h>
+#import <Foundation/NSSet.h>
+#import <Foundation/NSArray.h>
@implementation __NSCFURLSessionConfiguration
@end
@@ -20,19 +22,20 @@
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];
+ ((__NSCFURLSession *)sharedSession)->_isSharedSession = YES;
});
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 <NSURLSessionDelegate>)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 +59,66 @@
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) {
+ if (_isSharedSession) return;
+ _invalid = YES;
+ }
+ [self _notifyInvalidationIfReady];
+}
+
+- (void)invalidateAndCancel
+{
+ NSArray *tasks;
+ @synchronized(self) {
+ if (_isSharedSession) return;
+ _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 +421,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 +437,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..ef3fdbc 100644
--- a/src/URL/NSURLSession.m
+++ b/src/URL/NSURLSession.m
@@ -7,6 +7,12 @@
#import <Foundation/NSURLSession.h>
#import <objc/runtime.h>
+#import <Foundation/NSURLConnection.h>
+#import <Foundation/NSError.h>
+#import <Foundation/NSURLError.h>
+#import <Foundation/NSData.h>
+#import <Foundation/NSOperation.h>
+#import <dispatch/dispatch.h>
const int64_t NSURLSessionTransferSizeUnknown = -1LL;
NSString* const NSURLSessionDownloadTaskResumeData = @"NSURLSessionDownloadTaskResumeData";
@@ -28,7 +34,114 @@ 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;
+ }
+ void (^completion)(void) = ^{
+ @autoreleasepool {
+ if (handler) handler(data, response, error);
+ [session _taskDidComplete:self];
+ }
+ };
+ // Completion handlers follow the session's delegate queue, including its
+ // suspension and serialization. The queued block retains task/session/data.
+ if (session)
+ [[session delegateQueue] addOperationWithBlock:completion];
+ else
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), completion);
+ [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
+11
View File
@@ -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" "$@"
+36
View File
@@ -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()
+159
View File
@@ -0,0 +1,159 @@
"""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.proc / "uptime").write_text("1000 0\n")
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, age=120):
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')
fields = ['S'] + ['0'] * 18 + [str(int((1000 - age) * os.sysconf('SC_CLK_TCK')))]
(folder / 'stat').write_text(str(pid) + ' (Riot Client (worker)) ' + ' '.join(fields))
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, 'title': 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]', '[{}]', '[{"class":"editor"}]',
'[{"class":"Riot","title":"Riot","mapped":"true","hidden":false}]'):
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, []))
def test_young_client_prevents_recovery_even_if_an_old_client_exists(self):
self.riot()
self.process(125, '/Applications/Riot Client.app/Contents/MacOS/RiotClientServices', age=5)
self.assertEqual(self.run_recovery(), (False, []))
def test_missing_or_invalid_age_information_prevents_recovery(self):
proc = self.riot()
for value in ('garbage', '123 (worker) S'):
(proc / 'stat').write_text(value)
self.assertEqual(self.run_recovery(), (False, []))
(proc / 'stat').unlink()
self.assertEqual(self.run_recovery(), (False, []))
def test_shutdown_failures_are_reported_without_aborting_launch(self):
self.riot()
for error in (FileNotFoundError(), subprocess.TimeoutExpired('darling', 25),
subprocess.CalledProcessError(1, 'darling')):
def command(args, **kwargs):
if args[0] == 'hyprctl':
return subprocess.CompletedProcess(args, 0, stdout='[]')
raise error
with patch.object(recovery.subprocess, 'run', side_effect=command):
self.assertFalse(recovery.recover(self.proc))
def test_client_started_during_window_inspection_is_preserved(self):
self.riot()
def command(args, **kwargs):
self.assertEqual(args[0], 'hyprctl')
self.process(125, '/Applications/Riot Client.app/Contents/MacOS/RiotClientServices', age=1)
return subprocess.CompletedProcess(args, 0, stdout='[]')
with patch.object(recovery.subprocess, 'run', side_effect=command):
self.assertFalse(recovery.recover(self.proc))
def test_valid_unrelated_window_does_not_prevent_recovery(self):
self.riot()
result, calls = self.run_recovery([{'class':'editor', 'title':'file', 'mapped':True, 'hidden':False}])
self.assertTrue(result)
self.assertEqual(calls[-1], ['darling', 'shutdown'])
if __name__ == '__main__':
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
"""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)
from pathlib import Path
prefix = Path(os.environ['DPREFIX'])
assert not prefix.exists(), 'Darling must receive an absent prefix for first-time initialization'
prefix.mkdir()
assert sys.argv[1:3] == ['shell', 'env']
assert 'DYLD_FRAMEWORK_PATH' not in os.environ and 'DYLD_LIBRARY_PATH' not in os.environ
urls = [arg for arg in sys.argv[1:] if arg.startswith('http://')]
assert len(urls) == 2
assert urllib.request.urlopen(urls[0]).read() == b'darling-session-ok'
try:
urllib.request.urlopen(urls[1], timeout=2)
except OSError:
pass
else:
raise AssertionError('reserved error endpoint unexpectedly accepted a connection')
print('LIFECYCLE_PASS: fixture')
print('URLSESSION_PASS: fixture')
print('SHARED_SESSION_PASS: fixture')
print('QUEUE_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']).parent.exists())
self.assertEqual(stat.S_IMODE(log.stat().st_mode), 0o600)
if __name__ == '__main__':
unittest.main()
+32
View File
@@ -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')
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Opt-in recovery of an old windowless Riot session on Hyprland; fail closed."""
import json
import math
import os
from pathlib import Path
import subprocess
MIN_AGE_SECONDS = 60
def recoverable_processes(proc_root, prefix):
"""Return stable process identities, or None when shutdown is not justified."""
candidates = {}
try:
uptime = float((proc_root / 'uptime').read_text().split()[0])
ticks = os.sysconf('SC_CLK_TCK')
if not math.isfinite(uptime) or uptime < 0 or ticks <= 0:
return None
for proc in proc_root.iterdir():
if not proc.name.isdigit():
continue
try:
if proc.stat().st_uid != os.getuid():
continue
executable = (proc / 'cmdline').read_bytes().split(b'\0')[0].decode(errors='replace')
if executable.endswith('/LeagueofLegends'):
return None
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(Path.home() / '.darling'))))
if process_prefix != prefix:
continue
# Field 22 follows a comm field that may contain spaces/parentheses.
start_ticks = int((proc / 'stat').read_text().rsplit(')', 1)[1].split()[19])
age = uptime - start_ticks / ticks
if start_ticks < 0 or age < MIN_AGE_SECONDS:
return None # One young client prevents restarting the entire prefix.
candidates[proc.name] = start_ticks
except FileNotFoundError:
if proc.exists():
return None
continue
except (OSError, ValueError, IndexError):
return None
return candidates or None
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
processes = recoverable_processes(proc_root, prefix)
if not processes:
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:
if (not isinstance(window, dict) or
not all(isinstance(window.get(key), str) for key in ('class', 'title')) or
not all(type(window.get(key)) is bool for key in ('mapped', 'hidden'))):
return False
identity = (window['class'] + ' ' + window['title']).lower()
if any(name in identity for name in ('riot', 'league')) and window['mapped'] and not window['hidden']:
return False
except (OSError, ValueError, subprocess.SubprocessError):
return False
# Compositor inspection can take seconds: refuse if a game/client started,
# a PID was reused, or any relevant process disappeared during that interval.
if recoverable_processes(proc_root, prefix) != processes:
return False
print('Recovering a Riot session with no visible client window.', flush=True)
try:
subprocess.run(['darling', 'shutdown'], check=True, timeout=25)
except (OSError, subprocess.SubprocessError) as error:
print(f'Riot session recovery failed: {error}', flush=True)
return False
return True
if __name__ == '__main__':
recover()
+51
View File
@@ -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 <stdlib.h>
#include <string.h>
#include <strings.h>
#include <stdio.h>
#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)
+1 -1
View File
@@ -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]
+95
View File
@@ -0,0 +1,95 @@
// Standalone CFNetwork regression: only the host runner's loopback fixture is used.
#import <Foundation/Foundation.h>
#import <dispatch/dispatch.h>
#include <stdio.h>
#include <string.h>
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 <NSURLSessionDelegate>
@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;
}
static int queue_check(void) {
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
[queue setMaxConcurrentOperationCount:1];
[queue setSuspended:YES];
NSURLSession *session = [[NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:nil delegateQueue:queue] retain];
dispatch_semaphore_t completed = dispatch_semaphore_create(0);
__block int callbacks = 0, correctQueue = 0;
NSURLSessionDataTask *task = [session dataTaskWithURL:[NSURL URLWithString:probeURL] completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
correctQueue = [NSOperationQueue currentQueue] == queue;
++callbacks;
dispatch_semaphore_signal(completed);
}];
[task cancel];
[session finishTasksAndInvalidate];
// A suspended delegate queue must also suspend task completion delivery.
if (!dispatch_semaphore_wait(completed, dispatch_time(DISPATCH_TIME_NOW, 100*NSEC_PER_MSEC))) return 20;
[queue setSuspended:NO];
if (dispatch_semaphore_wait(completed, dispatch_time(DISPATCH_TIME_NOW, 15*NSEC_PER_SEC))) return 21;
if (callbacks != 1 || !correctQueue) return 22;
[session release]; [queue release];
puts("QUEUE_PASS: completion obeys delegate queue and suspension");
return 0;
}
int main(int argc, const char **argv) { @autoreleasepool {
if (argc != 3) { puts("Usage: probe <loopback-success-url> <loopback-error-url>"); 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;
[[NSURLSession sharedSession] finishTasksAndInvalidate];
[[NSURLSession sharedSession] invalidateAndCancel];
if (session_check(probeURL,0)) { puts("SHARED_SESSION_FAIL"); return 5; }
puts("SHARED_SESSION_PASS: invalidation leaves singleton usable");
if (queue_check()) { puts("QUEUE_FAIL"); return 6; }
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;
}}
+94
View File
@@ -0,0 +1,94 @@
#!/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)
# Darling initializes account/configuration files only for an absent prefix.
# Reserve its private parent, not DPREFIX itself.
private_root = Path(tempfile.mkdtemp(prefix='urlsession-', dir=root / 'build'))
prefix = private_root / 'prefix'
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')
log = args.log
result = None
try:
with log.open('w') as stream:
os.chmod(log, 0o600)
# Apply overlays to the probe only, after the private namespace boots.
# launchd/shellspawn must start against the installed base runtime.
result = subprocess.run(['darling', 'shell', 'env',
'DYLD_FRAMEWORK_PATH=/Volumes/SystemRoot' + str(frameworks.resolve()),
'DYLD_LIBRARY_PATH=/Volumes/SystemRoot' + str(libraries.resolve()),
'/Volumes/SystemRoot' + 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:', 'SHARED_SESSION_PASS:', 'QUEUE_PASS:',
'LIFECYCLE_FAIL', 'SHARED_SESSION_FAIL', 'QUEUE_FAIL', 'FAIL ', 'TIMEOUT')):
print(line)
required = ('LIFECYCLE_PASS:', 'URLSESSION_PASS:', 'SHARED_SESSION_PASS:', 'QUEUE_PASS:')
if result.returncode or not all(marker in text for marker in required):
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(private_root)
else:
print('Private test artifacts retained for inspection:', private_root)
except (OSError, subprocess.SubprocessError):
print('Private test artifacts retained for inspection:', private_root)
+6
View File
@@ -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"
+1 -1
View File
@@ -316,7 +316,7 @@
"revision": "e7e3db881008d883f82914765a72ce842bcba735",
"repository": "https://github.com/darlinghq/darling-cfnetwork.git",
"patch": "patches/cfnetwork.patch",
"modified_files": 1,
"modified_files": 4,
"new_files": []
},
{