Skip to content

fix: remove ShipIt's launchd job once installation completes - #331

Open
MarshallOfSound wants to merge 1 commit into
mainfrom
sam/remove-launchd-job-after-install
Open

MarshallOfSound wants to merge 1 commit into
mainfrom
sam/remove-launchd-job-after-install

Conversation

@MarshallOfSound

Copy link
Copy Markdown
Collaborator

Closes #21.

Jobs submitted with SMJobSubmit are registrations, not one-shots, so after an update the ShipIt job lingers in the launchd domain as "not running" until logout. macOS 27 now surfaces this as a Dock tile on the updated app after the user quits it (see this report).

  • ShipIt removes its own launchd job on the terminal success paths (install completed / cancelled because the app relaunched), just before exit(0). Failure exits keep the job so KeepAlive still retries interrupted installs.
  • Removal passes a NULL authorization: by then the installer has replaced the bundle containing the running ShipIt, so authd can't validate its on-disk signature and AuthorizationCreate fails with errAuthorizationDenied even as root. SMJobRemove(NULL) authorizes on the caller (root → system domain, otherwise the caller's user domain), never prompts, and works with the binary gone. This also means the 2013 concern above about re-prompting for the privileged path doesn't apply.
  • SIGTERM is ignored during removal (launchd TERMs a job it's removing) and wait=false avoids deadlocking on our own exit.
  • Test fixtures treat a vanished job as a clean exit, and a new spec asserts the job is gone after a remote install.

Validated beyond the suite (108/0): a system-domain end-to-end run of the built ShipIt as root — real 1.0→2.1 install with the privileged target guard satisfied — self-removed from the system domain with no authorization prompt, and deleted-binary probes confirm SMJobRemove(NULL) succeeds in both domains after the running binary is unlinked.

A job submitted with SMJobSubmit is a registration, not a one-shot: after
ShipIt exits, the job stays in the launchd domain as "not running" until
the login session ends. macOS 27 shows a Dock tile for any app with a
registered background job, so an updated-and-quit app looks like it is
still running (#21).

ShipIt now removes its own job on the terminal success paths (install
completed, or cancelled because the app relaunched), right before
exit(0). Failure exits keep the registration so KeepAlive can respawn
ShipIt to retry the install.

The removal passes a NULL authorization: by that point the installer has
replaced the bundle containing the running ShipIt binary, so authd can no
longer validate its code signature and AuthorizationCreate fails with
errAuthorizationDenied even for root. SMJobRemove with NULL authorization
authorizes on the caller instead — root may modify the system domain and
any caller its own user domain — which never prompts and works with the
binary already gone. SIGTERM is ignored during removal because launchd
terminates a job it is removing, and wait=false avoids deadlocking on our
own exit.

@VerteDinde VerteDinde left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read through this against the launcher/updater side looking for ways the self-removal could bite (KeepAlive/Mach-demand respawn after wait=false, pgroup kill of the relaunched app, wrong domain, exit-0 paths that skip it, the app resubmitting concurrently) and it holds up. A few small things inline.

One heads-up for the Electron roll rather than this repo: Electron's GN ShipIt executable target only links AppKit/Foundation/IOKit/Security, so it'll need ServiceManagement.framework added when DEPS picks this up.

Comment thread Squirrel/ShipIt-main.m
CFErrorRef cfError = NULL;
if (!SMJobRemove(domain, (__bridge CFStringRef)jobLabel, NULL, false, &cfError)) {
NSError *error = CFBridgingRelease(cfError);
NSLog(@"Could not remove ShipIt launchd job %@: %@", jobLabel, error);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: worth swallowing kSMErrorJobNotFound here like SQRLShipItLauncher and the test fixture do — it happens legitimately if ShipIt is run outside launchd, or if a just-relaunched app's launchPrivileged: removes/resubmits the label before we get here.

Comment thread Squirrel/ShipIt-main.m
// caller itself — root may modify the system domain and any caller its
// own user domain — which neither consults authd nor can present a
// prompt, and works with the binary already gone.
CFStringRef domain = (geteuid() == 0 ? kSMDomainSystemLaunchd : kSMDomainUserLaunchd);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: this only clears the domain this run lives in. Someone who previously did an admin-prompted (system-domain) update keeps that stale job indefinitely and later unprivileged runs can't remove it, so the Dock tile could persist for them. Intentional / worth a note?

Comment thread Squirrel/ShipIt-main.m
// launchd terminates a running job as part of removing it. Ignore
// SIGTERM so we still exit through our own exit() call with the
// intended status rather than dying by signal mid-cleanup.
signal(SIGTERM, SIG_IGN);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: if the install lands during logout/restart (the on-demand-only case) and the SMJobRemove IPC stalls, we now ignore launchd's TERM and hold the session for ExitTimeOut until KILL. Probably fine, but an alarm() here would bound it.

// causes macOS 27 to show a Dock tile for the updated app after quit.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
expect(CFBridgingRelease(SMJobCopyDictionary(kSMDomainUserLaunchd, (__bridge CFStringRef)self.shipItDirectoryManager.applicationIdentifier))).withTimeout(SQRLLongTimeout).toEventually(beNil());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: in the suite ShipIt runs from the test host's Squirrel.framework, so the running binary isn't inside the bundle being replaced. The production case (binary swapped for a differently-signed ShipIt at the same path, user domain, NULL auth) is only covered by the manual probes in the description — fine, just noting it isn't under CI.

if (job == nil || job[@"PID"] != nil) return nil;
// ShipIt removes its own launchd job when it finishes successfully, so
// once the job has been submitted, its disappearance means a clean exit.
if (job == nil) return @0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this makes "never submitted" and "succeeded and removed" indistinguishable, so a launch failure now sails through SQRLWaitForShipIt and only trips on the later version assertion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Remove launchd job after update installation completes

2 participants