Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ test-dist:
bash phase3-binary/dist/test/install_referral_handoff.test.sh
bash phase3-binary/dist/test/install_fresh_evidence.test.sh
bash phase3-binary/dist/test/install_upgrade_evidence_rollback.test.sh
bash phase3-binary/dist/test/install_launchd_migration.test.sh
bash phase3-binary/dist/test/install_lifecycle_state.test.sh
bash phase3-binary/dist/test/install_transaction_lock.test.sh
bash phase3-binary/dist/test/install_coordinator_admission.test.sh
Expand Down
12 changes: 6 additions & 6 deletions ops/macprovider-watchdog/watchdog.sh
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ def verify_root():
raise RuntimeError(f"not_directory:{path}")

def read_marker():
fd = os.open(pending, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
fd = os.open(pending, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0))
try:
raw = os.read(fd, 65536)
finally:
Expand Down Expand Up @@ -508,7 +508,7 @@ def current_binary_version(path):

def read_success_sentinel(path):
reject_path(path)
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0))
try:
payload = json.loads(os.read(fd, 65536).decode("utf-8"))
finally:
Expand Down Expand Up @@ -569,7 +569,7 @@ def process_success_sentinel(marker):

def sha256(path):
h = hashlib.sha256()
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0))
try:
while True:
chunk = os.read(fd, 1024 * 1024)
Expand Down Expand Up @@ -1038,7 +1038,7 @@ def validate_malibu_app_backup(release_backup):
state_st = reject_path(state_path)
if not stat.S_ISREG(archive_st.st_mode) or not stat.S_ISREG(state_st.st_mode):
raise RuntimeError("malibu_backup_not_regular")
fd = os.open(state_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
fd = os.open(state_path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0))
try:
raw = os.read(fd, 65537)
finally:
Expand Down Expand Up @@ -1150,7 +1150,7 @@ def fsync_release_tree(root_path):
directories.append(current)
for name in file_names:
path = os.path.join(current, name)
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0))
try:
os.fsync(fd)
finally:
Expand All @@ -1165,7 +1165,7 @@ def fsync_release_tree(root_path):
def atomic_copy_binary(source, target, mode):
temporary = os.path.join(os.path.dirname(target), f".macprovider-cli.rollback-restore-{uuid.uuid4()}")
try:
source_fd = os.open(source, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
source_fd = os.open(source, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0))
try:
destination_fd = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0), mode)
try:
Expand Down
33 changes: 28 additions & 5 deletions phase3-binary/app/Sources/Malibu/Agent/MalibuAgent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ final class MalibuAgent: ObservableObject {
private var reconnectTask: Task<Void, Never>?
private var providerLogTail: ProviderLogTail?
private var providerLogTailCancellable: AnyCancellable?
private var watchdogLogTailCancellable: AnyCancellable?
private var watchdogLogLines: [String] = []
private var reconnect = ReconnectPolicy()
private let thermalMonitor = ThermalMonitor()
private var cancellables: Set<AnyCancellable> = []
Expand Down Expand Up @@ -109,7 +111,7 @@ final class MalibuAgent: ObservableObject {
}
guard !isShuttingDown else { return }

if let failure = diagnosedProviderFailure() {
if let failure = diagnosedProviderFailure(includingLaunchdState: true) {
providerStartFailure = failure
snapshot.state = .error
snapshot.lastError = failure
Expand Down Expand Up @@ -525,7 +527,7 @@ final class MalibuAgent: ObservableObject {
let sleep = min(pollInterval, remaining)
try? await Task.sleep(nanoseconds: UInt64(sleep * 1_000_000_000))
}
let failure = diagnosedProviderFailure()
let failure = diagnosedProviderFailure(includingLaunchdState: true)
?? ProviderLogDiagnostics.timeoutMessage(logHint: ProviderLogDiagnostics.logHint())
providerStartFailure = failure
snapshot.state = .error
Expand Down Expand Up @@ -853,7 +855,7 @@ final class MalibuAgent: ObservableObject {
await MainActor.run {
self.snapshot.invalidateLocalStatusObservation()
self.invalidateProviderProjectionFreshness()
if let failure = self.diagnosedProviderFailure() {
if let failure = self.diagnosedProviderFailure(includingLaunchdState: true) {
self.providerStartFailure = failure
self.snapshot.state = .error
self.snapshot.lastError = failure
Expand Down Expand Up @@ -1418,25 +1420,46 @@ final class MalibuAgent: ObservableObject {

private func startProviderLogTail(paths: ProviderPaths = .current) {
providerLogTailCancellable?.cancel()
watchdogLogTailCancellable?.cancel()
providerLogTail?.stop()
let tail = ProviderLogTail()
providerLogTailCancellable = tail.$lines
.sink { [weak self] lines in
self?.logLines = lines
}
watchdogLogTailCancellable = tail.$watchdogLines
.sink { [weak self] lines in
self?.watchdogLogLines = lines
}
providerLogTail = tail
tail.start(paths: paths)
}

private func stopProviderLogTail() {
providerLogTailCancellable?.cancel()
providerLogTailCancellable = nil
watchdogLogTailCancellable?.cancel()
watchdogLogTailCancellable = nil
providerLogTail?.stop()
providerLogTail = nil
watchdogLogLines = []
}

private func diagnosedProviderFailure() -> String? {
ProviderLogDiagnostics.diagnose(lines: logLines)?.userMessage
private func diagnosedProviderFailure(includingLaunchdState: Bool = false) -> String? {
let launchdNeedsRepair = StartupState.launchdInstallEvidenceExists()
&& InstalledProviderMonitor.launchdServiceRepairState().needsRepair
if let finding = ProviderLogDiagnostics.diagnose(
providerLines: logLines,
watchdogLines: watchdogLogLines,
launchdNeedsRepair: launchdNeedsRepair
), ProviderLogDiagnostics.isActionable(finding, launchdNeedsRepair: launchdNeedsRepair) {
return finding.userMessage
}
guard includingLaunchdState,
launchdNeedsRepair else {
return nil
}
return ProviderLogDiagnostics.staleLaunchAgentMessage
}

private func scheduleReconnect() async {
Expand Down
33 changes: 31 additions & 2 deletions phase3-binary/app/Sources/Malibu/MalibuApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// in-App via LaunchProviderController (SPEC-026 §7.2, follow-up impl
// in this same PR).

private func presentOnboarding(replacementConfirmed: Bool = false) {
private func presentOnboarding(
replacementConfirmed: Bool = false,
repairExistingInstall: Bool = false
) {
if onboardingWindow == nil {
onboardingWindow = OnboardingWindow.make(
agent: agent,
replacementConfirmed: replacementConfirmed
replacementConfirmed: replacementConfirmed,
repairExistingInstall: repairExistingInstall
) { [weak self] in
self?.onboardingWindow?.close()
self?.onboardingWindow = nil
Expand All @@ -144,8 +148,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
switch route {
case .startAgent:
await agent.start()
case .startAgentAndRepairWatchdog:
await agent.start()
// Starting a healthy provider is safe to do automatically, but
// reinstalling it can replace the running binary and launchd
// identity. Keep that mutation behind the explicit repair action.
presentOnboarding(repairExistingInstall: true)
case .repairExistingInstall:
presentOnboarding(repairExistingInstall: true)
case .showOnboarding:
presentOnboarding(replacementConfirmed: replacementConfirmed)
case .showLaunchdConflict:
presentLaunchdConflict()
case .quit:
NSApp.terminate(nil)
case .showImportDialog:
Expand Down Expand Up @@ -210,6 +224,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return StartFreshConfirmationCopy.confirms(alert.runModal())
}

private func presentLaunchdConflict() {
let alert = NSAlert()
alert.messageText = LaunchdConflictCopy.title
alert.informativeText = LaunchdConflictCopy.message
alert.alertStyle = .warning
alert.addButton(withTitle: LaunchdConflictCopy.dismissButton)
alert.runModal()
}

private func presentMigrationError(_ error: Error) -> Bool {
let alert = NSAlert()
alert.messageText = MigrationErrorCopy.title
Expand Down Expand Up @@ -352,6 +375,12 @@ enum StartFreshConfirmationCopy {
}
}

enum LaunchdConflictCopy {
static let title = "Another background service needs attention"
static let message = "Malibu found a background service in the provider slot that it does not own. Automatic repair is disabled so Malibu will not replace another program. Disable or remove that service, then reopen Malibu."
static let dismissButton = "OK"
}

enum MigrationErrorCopy {
static let title = "Could not use existing provider"
static let cancelButton = "Cancel"
Expand Down
25 changes: 22 additions & 3 deletions phase3-binary/app/Sources/Malibu/Onboarding/CLIInstallRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ enum CLIInstallRunner {
pinnedVersion: String? = nil,
referralCode: String? = nil,
replacingIncumbentProvider: Bool = false,
repairExistingInstall: Bool = false,
onLogLine: @escaping @Sendable @MainActor (String) -> Void
) async throws {
let scriptURL = try resolveInstallScriptURL()
Expand All @@ -78,7 +79,8 @@ enum CLIInstallRunner {
installPort: installPort,
pinnedVersion: pinnedVersion,
referralCodeFile: referralFileURL,
replacingIncumbentProvider: replacingIncumbentProvider
replacingIncumbentProvider: replacingIncumbentProvider,
repairExistingInstall: repairExistingInstall
)
if let installPort {
await onLogLine("[macprovider-install] Using local HTTP port \(installPort) for provider install.")
Expand Down Expand Up @@ -147,7 +149,10 @@ enum CLIInstallRunner {
installPort: Int?,
pinnedVersion: String?,
referralCodeFile: URL? = nil,
replacingIncumbentProvider: Bool = false
replacingIncumbentProvider: Bool = false,
repairExistingInstall: Bool = false,
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,
fileManager: FileManager = .default
) throws -> [String: String] {
// Deliberately do not inherit the parent environment. install.sh has
// authority-changing knobs for repositories, public keys, acceptance
Expand All @@ -157,11 +162,19 @@ enum CLIInstallRunner {
_ = parentEnvironment
var explicit = [
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin",
"HOME": NSHomeDirectory(),
"HOME": homeDirectory.path,
"TMPDIR": "/tmp",
"LC_ALL": "C",
"MACPROVIDER_NO_PROMPT": "1",
]
let configuredProgram = InstalledProviderMonitor.configuredProviderProgram(
homeDirectory: homeDirectory,
fileManager: fileManager
)
let defaultProgram = homeDirectory.appendingPathComponent("macprovider/macprovider-cli").standardizedFileURL
if configuredProgram != defaultProgram {
explicit["MACPROVIDER_INSTALL_DIR"] = configuredProgram.deletingLastPathComponent().path
}
if let installPort {
explicit["MACPROVIDER_PORT"] = String(installPort)
}
Expand All @@ -177,6 +190,9 @@ enum CLIInstallRunner {
explicit["MACPROVIDER_REFERRAL_REPLACE_INCUMBENT"] = "1"
}
}
if repairExistingInstall {
explicit["MACPROVIDER_REPAIR_EXISTING_INSTALL"] = "1"
}
return try ProcessEnvironmentSanitizer.sanitized(
from: [:],
extraEnvironment: explicit
Expand All @@ -197,6 +213,9 @@ enum CLIInstallRunner {
atPath: NSHomeDirectory() + "/Library/LaunchAgents/live.streamvc.macprovider.plist"
)
guard hasManifest || launchdPlist else { return false }
guard InstalledProviderMonitor.launchdServiceRepairState() == .validExecutable else {
return false
}
return await InstalledProviderMonitor.isHealthy(port: port)
}

Expand Down
Loading
Loading