From 1d4f7fae0c8723bca5bceaef7611dd9e0778c1d1 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:16:32 +0200 Subject: [PATCH] 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. --- docs/TESTING.md | 11 +++-- docs/TROUBLESHOOTING.md | 5 ++ patches/cfnetwork.patch | 39 ++++++++++------ tests/test_hidden_riot_recovery.py | 49 ++++++++++++++++++-- tests/test_urlsession_runner.py | 2 + tools/recover-hidden-riot-client.py | 72 +++++++++++++++++++++-------- tools/riot-urlsession-check.m | 28 +++++++++++ tools/riot-urlsession-check.py | 6 ++- 8 files changed, 171 insertions(+), 41 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index 7ff2ef6..e0824de 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 7a2aa9c..04242ba 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -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. diff --git a/patches/cfnetwork.patch b/patches/cfnetwork.patch index 4085574..46ce9c0 100644 --- a/patches/cfnetwork.patch +++ b/patches/cfnetwork.patch @@ -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 #import @@ -176,11 +182,12 @@ index 39367a5..625e88d 100644 +#import +#import +#import ++#import +#import 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]; +} diff --git a/tests/test_hidden_riot_recovery.py b/tests/test_hidden_riot_recovery.py index eecf09e..247047c 100644 --- a/tests/test_hidden_riot_recovery.py +++ b/tests/test_hidden_riot_recovery.py @@ -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() diff --git a/tests/test_urlsession_runner.py b/tests/test_urlsession_runner.py index 633eb88..1c960e4 100644 --- a/tests/test_urlsession_runner.py +++ b/tests/test_urlsession_runner.py @@ -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' diff --git a/tools/recover-hidden-riot-client.py b/tools/recover-hidden-riot-client.py index 58870ea..47bf93f 100644 --- a/tools/recover-hidden-riot-client.py +++ b/tools/recover-hidden-riot-client.py @@ -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 diff --git a/tools/riot-urlsession-check.m b/tools/riot-urlsession-check.m index ac80798..f3f8bdc 100644 --- a/tools/riot-urlsession-check.m +++ b/tools/riot-urlsession-check.m @@ -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 "); 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"); diff --git a/tools/riot-urlsession-check.py b/tools/riot-urlsession-check.py index 6738c94..7545ba1 100644 --- a/tools/riot-urlsession-check.py +++ b/tools/riot-urlsession-check.py @@ -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()