diff --git a/patches/squirrel.mac/.patches b/patches/squirrel.mac/.patches index 909d39694a..222ced0da8 100644 --- a/patches/squirrel.mac/.patches +++ b/patches/squirrel.mac/.patches @@ -10,3 +10,4 @@ chore_turn_off_launchapplicationaturl_deprecation_errors_in_squirrel.patch fix_crash_when_process_to_extract_zip_cannot_be_launched.patch use_uttype_class_instead_of_deprecated_uttypeconformsto.patch fix_clean_up_orphaned_staged_updates_before_downloading_new_update.patch +fix_trigger_shipit_mach_service_after_smjobsubmit_to_unblock.patch diff --git a/patches/squirrel.mac/fix_trigger_shipit_mach_service_after_smjobsubmit_to_unblock.patch b/patches/squirrel.mac/fix_trigger_shipit_mach_service_after_smjobsubmit_to_unblock.patch new file mode 100644 index 0000000000..0e1e20c9e7 --- /dev/null +++ b/patches/squirrel.mac/fix_trigger_shipit_mach_service_after_smjobsubmit_to_unblock.patch @@ -0,0 +1,139 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Keeley Hammond +Date: Tue, 14 Apr 2026 10:00:00 -0700 +Subject: fix: trigger ShipIt Mach service after SMJobSubmit to unblock + on-demand-only mode + +When a macOS system update is pending (downloaded but not yet installed), +launchd puts the user domain (gui/) into "on-demand-only mode". +In this mode, launchd only starts jobs triggered by an on-demand event +such as a Mach port connection -- KeepAlive and RunAtLoad are suppressed. + +ShipIt's launchd job registers a MachServices endpoint, but nothing ever +connects to it. The MachServices key was originally used when ShipIt was +a full XPC service (removed in Squirrel/Squirrel.Mac@d6ca1c2 in October +2013). The client-side connection was removed but the server-side +MachServices registration was left behind, creating a trigger that +launchd waits on but nothing ever fires. + +Fix: after SMJobSubmit, open a lightweight XPC connection to the +registered Mach service name and send an empty message. This satisfies +launchd's on-demand trigger and causes it to start ShipIt immediately. +In normal operation (no pending update), the job starts via KeepAlive +anyway and the trigger is a harmless no-op. Unlike launchctl kickstart, +this preserves KeepAlive.SuccessfulExit respawn behavior because launchd +treats the activation as a legitimate on-demand event. + +The trigger message stays in the Mach port's kernel queue (ShipIt has +no XPC listener), which creates standing demand that provides the +on-demand activity needed for KeepAlive retries in on-demand-only mode. +To prevent this standing demand from also respawning ShipIt after a +successful exit(0), ShipIt checks in for its Mach service port and +dequeues every pending message before each exit(EXIT_SUCCESS). Checking +in alone is not sufficient: launchd tracks demand independently of the +port's lifetime and will respawn the job if the message was never read. +On failure exits, the message is left in place so launchd treats the +KeepAlive respawn as demand-backed. + +diff --git a/Squirrel/SQRLShipItLauncher.m b/Squirrel/SQRLShipItLauncher.m +index 6a9151d92f399184fff9854eb00ea506165bbbe2..a087f20043fa79a07391ed065031396d7ec6fce4 100644 +--- a/Squirrel/SQRLShipItLauncher.m ++++ b/Squirrel/SQRLShipItLauncher.m +@@ -10,6 +10,7 @@ + #import + #import "SQRLDirectoryManager.h" + #import ++#import + #import + #import + #import +@@ -57,7 +58,7 @@ + (RACSignal *)shipItJobDictionary { + NSMutableArray *arguments = [[NSMutableArray alloc] init]; + [arguments addObject:[squirrelBundle URLForResource:@"ShipIt" withExtension:nil].path]; + +- // Pass in the service name so ShipIt knows how to broadcast itself. ++ // Pass in the job label so ShipIt can identify itself. + [arguments addObject:jobLabel]; + + // We need to pass the path to ShipIt rather than having ShipIt +@@ -154,6 +155,23 @@ + (RACSignal *)launchPrivileged:(BOOL)privileged { + return [RACSignal error:CFBridgingRelease(cfError)]; + } + ++ // Trigger an on-demand launch by sending a message to the job's ++ // Mach service. When loginwindow begins a restart (e.g. for a ++ // pending macOS update) it puts the per-user launchd domain into ++ // on-demand-only mode, which defers RunAtLoad/KeepAlive spawns ++ // but still honors real IPC demand. The system domain is not ++ // affected, so this is only needed for the unprivileged path. ++ if (!privileged) { ++ xpc_connection_t trigger = xpc_connection_create_mach_service(self.shipItJobLabel.UTF8String, NULL, 0); ++ xpc_connection_set_event_handler(trigger, ^(xpc_object_t __unused event) {}); ++ xpc_connection_resume(trigger); ++ xpc_connection_send_message(trigger, xpc_dictionary_create(NULL, NULL, 0)); ++ // send_message is async; keep the connection alive until the ++ // message is actually on the wire so ARC releasing `trigger` ++ // at end-of-scope can't drop it first. ++ xpc_connection_send_barrier(trigger, ^{ (void)trigger; }); ++ } ++ + return [RACSignal empty]; + }] + flatten] +diff --git a/Squirrel/ShipIt-main.m b/Squirrel/ShipIt-main.m +index acf545199dbf1831fe8a73155c6e4d0db4047934..e26c0f11870ddbe801e572b1696af484231bf1dc 100644 +--- a/Squirrel/ShipIt-main.m ++++ b/Squirrel/ShipIt-main.m +@@ -15,6 +15,8 @@ + + #include + #include ++#include ++#include + + #import "NSError+SQRLVerbosityExtensions.h" + #import "RACSignal+SQRLTransactionExtensions.h" +@@ -63,6 +65,28 @@ static BOOL clearInstallationAttempts(NSString *applicationIdentifier) { + return CFPreferencesSynchronize((__bridge CFStringRef)applicationIdentifier, kCFPreferencesCurrentUser, kCFPreferencesCurrentHost); + } + ++// Drain the Mach service port registered via MachServices in the launchd ++// job dictionary before exit(0) so launchd sees no outstanding demand and ++// does not immediately respawn the job. bootstrap_check_in transfers the ++// receive right into this task, but that alone is not sufficient: launchd ++// tracks demand independently of the port's lifetime, so the queued ++// trigger message must be explicitly dequeued. On failure exits the ++// message is intentionally left queued so the KeepAlive respawn is ++// demand-backed while the launchd domain is in on-demand-only mode. ++static void drainMachServicePort(const char *serviceName) { ++ mach_port_t port = MACH_PORT_NULL; ++ if (bootstrap_check_in(bootstrap_port, serviceName, &port) != KERN_SUCCESS) return; ++ ++ struct { ++ mach_msg_header_t header; ++ uint8_t body[4096]; ++ } msg; ++ while (mach_msg(&msg.header, MACH_RCV_MSG | MACH_RCV_TIMEOUT, ++ 0, sizeof(msg), port, 0, MACH_PORT_NULL) == KERN_SUCCESS) { ++ mach_msg_destroy(&msg.header); ++ } ++} ++ + // Waits for all instances of the target application (as described in the + // `request`) to exit, then sends completed. + static RACSignal *waitForTerminationIfNecessary(SQRLShipItRequest *request) { +@@ -206,12 +230,14 @@ static void installRequest(RACSignal *readRequestSignal, NSString *applicationId + if ([[error domain] isEqual:SQRLInstallerErrorDomain] && [error code] == SQRLInstallerErrorAppStillRunning) { + NSLog(@"Installation cancelled: %@", error); + clearInstallationAttempts(applicationIdentifier); ++ drainMachServicePort(applicationIdentifier.UTF8String); + exit(EXIT_SUCCESS); + } else { + NSLog(@"Installation error: %@", error); + exit(EXIT_FAILURE); + } + } completed:^{ ++ drainMachServicePort(applicationIdentifier.UTF8String); + exit(EXIT_SUCCESS); + }]; + }