fix(cfnetwork): preserve shared sessions and delegate-queue completions

Ignore both invalidation methods on the shared session; deliver task completion through the owning session delegate queue. Extend the standalone probe to exercise singleton reuse and a suspended custom queue. Require stable old-client process identities and complete window data before optional recovery, and continue launch after shutdown failures.
This commit is contained in:
lucannez64 committed 2026-10-18 14:16:32 +02:00
1 parent 0087384a02
commit e07cf72a15
8 files changed
+171 -41

No files matched your search

+8 -3
View File
@@ -31,7 +31,9 @@ The patch check uses temporary base indexes and does not reset or modify the sou
## NSURLSession lifecycle regression
After configuring and building the patched Darling source:
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
@@ -53,8 +55,11 @@ overlays, and keeps diagnostics in mode-0600 `build/urlsession-check.log`.
Expected checks: five successful HTTP GETs with exact response bytes/status;
connection refusal delivered as an error; repeated resume/cancel without
duplicate completion; finish waiting for task completion; and session
invalidation delivered once for both graceful finish and cancellation.
Expected output includes `URLSESSION_PASS` and `LIFECYCLE_PASS`. The original
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.
These checks do not establish full NSURLSession conformance or transport-level
interruption of an already-running request.
+5
View File
@@ -412,3 +412,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.
+26 -13
View File
@@ -1,5 +1,5 @@
diff --git a/include/CFNetwork/NSCFURLSession.h b/include/CFNetwork/NSCFURLSession.h
index e2326a3..b7489da 100644
index e2326a3..32efa89 100644
--- a/include/CFNetwork/NSCFURLSession.h
+++ b/include/CFNetwork/NSCFURLSession.h
@@ -2,7 +2,7 @@
@@ -11,12 +11,13 @@ index e2326a3..b7489da 100644
__attribute__((visibility("hidden")))
@interface __NSCFURLSessionConfiguration : NSObject
@@ -19,6 +19,8 @@ __attribute__((visibility("hidden")))
@@ -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;
@@ -38,7 +39,7 @@ index 91c0d0f..d6e3daf 100644
NULL, // equal
NULL, // hash
diff --git a/src/URL/NSCFURLSession.m b/src/URL/NSCFURLSession.m
index 95a726b..f5ff824 100644
index 95a726b..86291e8 100644
--- a/src/URL/NSCFURLSession.m
+++ b/src/URL/NSCFURLSession.m
@@ -9,6 +9,8 @@
@@ -50,12 +51,13 @@ index 95a726b..f5ff824 100644
@implementation __NSCFURLSessionConfiguration
@end
@@ -20,19 +22,19 @@
@@ -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;
}
@@ -73,7 +75,7 @@ index 95a726b..f5ff824 100644
}
+ (void)_releaseProcessAssertionForSessionIdentifier:(NSString *)identifier
@@ -56,12 +58,62 @@
@@ -56,12 +59,66 @@
return nil;
}
_nsCFConfig = (__NSCFURLSessionConfiguration *)[configuration retain];
@@ -109,7 +111,10 @@ index 95a726b..f5ff824 100644
+
+- (void)finishTasksAndInvalidate
+{
+ @synchronized(self) { _invalid = YES; }
+ @synchronized(self) {
+ if (_isSharedSession) return;
+ _invalid = YES;
+ }
+ [self _notifyInvalidationIfReady];
+}
+
@@ -117,6 +122,7 @@ index 95a726b..f5ff824 100644
+{
+ NSArray *tasks;
+ @synchronized(self) {
+ if (_isSharedSession) return;
+ _invalid = YES;
+ tasks = [[_activeDataTasks allObjects] copy];
+ }
@@ -138,7 +144,7 @@ index 95a726b..f5ff824 100644
- (void)delegate_didFinishEventsForBackgroundURLSession
{
if ([self can_delegate_didFinishEventsForBackgroundURLSession])
@@ -364,7 +416,13 @@
@@ -364,7 +421,13 @@
- (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSData *data, NSURLResponse *response, NSError *error))completionHandler
{
@@ -153,7 +159,7 @@ index 95a726b..f5ff824 100644
}
- (NSURLSessionDataTask *)dataTaskWithURL:(NSURL *)url
@@ -374,9 +432,7 @@
@@ -374,9 +437,7 @@
- (NSURLSessionDataTask *)dataTaskWithURL:(NSURL *)url completionHandler:(void (^)(NSData *data, NSURLResponse *response, NSError *error))completionHandler
{
@@ -165,10 +171,10 @@ index 95a726b..f5ff824 100644
- (NSURLSessionDataTask *)dataTaskWithHTTPGetRequest:(NSURL *)url
diff --git a/src/URL/NSURLSession.m b/src/URL/NSURLSession.m
index 39367a5..625e88d 100644
index 39367a5..ef3fdbc 100644
--- a/src/URL/NSURLSession.m
+++ b/src/URL/NSURLSession.m
@@ -7,6 +7,11 @@
@@ -7,6 +7,12 @@
#import <Foundation/NSURLSession.h>
#import <objc/runtime.h>
@@ -176,11 +182,12 @@ index 39367a5..625e88d 100644
+#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 +33,108 @@ NSString* const NSURLSessionDownloadTaskResumeData = @"NSURLSessionDownloadTaskR
@@ -28,7 +34,114 @@ NSString* const NSURLSessionDownloadTaskResumeData = @"NSURLSessionDownloadTaskR
@implementation NSURLSessionTask
@end
@@ -227,12 +234,18 @@ index 39367a5..625e88d 100644
+ session = _taskSession;
+ _taskSession = nil;
+ }
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
+ 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];
+}
+46 -3
View File
@@ -21,6 +21,7 @@ class RecoveryTests(unittest.TestCase):
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()
@@ -29,11 +30,13 @@ class RecoveryTests(unittest.TestCase):
self.env_patch.start()
self.addCleanup(self.env_patch.stop)
def process(self, pid, executable, prefix=None):
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):
@@ -59,7 +62,7 @@ class RecoveryTests(unittest.TestCase):
def test_visible_client_is_preserved(self):
self.riot()
for identity in ('Riot Client', 'League of Legends'):
result, calls = self.run_recovery([{'class': identity, 'mapped': True, 'hidden': False}])
result, calls = self.run_recovery([{'class': identity, 'title': identity, 'mapped': True, 'hidden': False}])
self.assertFalse(result)
self.assertEqual(len(calls), 1)
@@ -96,7 +99,8 @@ class RecoveryTests(unittest.TestCase):
def test_malformed_compositor_response_is_preserved(self):
self.riot()
for raw in ('invalid', '{}', '[null]'):
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)
@@ -111,6 +115,45 @@ class RecoveryTests(unittest.TestCase):
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()
+2
View File
@@ -32,6 +32,8 @@ 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'
+52 -20
View File
@@ -1,40 +1,60 @@
#!/usr/bin/env python3
"""Opt-in recovery of a windowless Riot session on Hyprland; fail closed elsewhere."""
"""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 recover(proc_root=Path('/proc')):
home = Path.home()
prefix = Path(os.environ.get('DPREFIX', str(home / '.darling'))).expanduser()
if prefix != home / '.darling':
return False # Custom prefixes are not validated by this desktop helper.
riot_running = False
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
argv = (proc / 'cmdline').read_bytes().split(b'\0')
executable = argv[0].decode(errors='replace')
executable = (proc / 'cmdline').read_bytes().split(b'\0')[0].decode(errors='replace')
if executable.endswith('/LeagueofLegends'):
return False # Never restart a running game, even without a window.
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(home / '.darling'))))
if process_prefix == prefix:
riot_running = True
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):
return False # Unknown ownership/visibility must not trigger shutdown.
if not riot_running:
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,
@@ -43,13 +63,25 @@ def recover(proc_root=Path('/proc')):
if not isinstance(windows, list):
return False
for window in windows:
identity = (window.get('class', '') + ' ' + window.get('title', '')).lower()
if any(name in identity for name in ('riot', 'league')) and window.get('mapped') and not window.get('hidden'):
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
except (OSError, ValueError, TypeError, AttributeError, subprocess.SubprocessError):
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)
subprocess.run(['darling', 'shutdown'], check=True, timeout=25)
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
+28
View File
@@ -53,6 +53,29 @@ static int lifecycle_check(int cancel) {
[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]];
@@ -60,6 +83,11 @@ int main(int argc, const char **argv) { @autoreleasepool {
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");
+4 -2
View File
@@ -68,9 +68,11 @@ try:
env=env, stdout=stream, stderr=subprocess.STDOUT, timeout=90)
text = log.read_text(errors='replace')
for line in text.splitlines():
if line.startswith(('LIFECYCLE_PASS:', 'URLSESSION_PASS:', 'LIFECYCLE_FAIL', 'FAIL ', 'TIMEOUT')):
if line.startswith(('LIFECYCLE_PASS:', 'URLSESSION_PASS:', 'SHARED_SESSION_PASS:', 'QUEUE_PASS:',
'LIFECYCLE_FAIL', 'SHARED_SESSION_FAIL', 'QUEUE_FAIL', 'FAIL ', 'TIMEOUT')):
print(line)
if result.returncode or 'LIFECYCLE_PASS:' not in text or 'URLSESSION_PASS:' not in text:
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()