mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-26 19:47:42 +00:00
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:
commit
b478010eb7
18 files changed
+1101
-12
No files matched your search
@@ -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'
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Executable
+11
@@ -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" "$@"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}}
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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": []
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user