From f3c90fbc328e65a5a7ff4c644c79a575894cfb7b Mon Sep 17 00:00:00 2001 From: Jarrod Davis Date: Sat, 1 Jul 2023 15:21:33 -0600 Subject: [PATCH 01/15] Start Work on CI --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a9ff03c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +# This workflow will build a Swift project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift + +name: Swift + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + run: + strategy: + fail-fast: false + matrix: + os: ["macos-13"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + - name: Build + run: swift build -v + - name: Run + run: swift run -v From adf6f631709dd4c3c9484d36bbdf91d3fd3c5307 Mon Sep 17 00:00:00 2001 From: Jarrod Davis Date: Sat, 1 Jul 2023 15:24:13 -0600 Subject: [PATCH 02/15] Install Swift 5.8 --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9ff03c..578788b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ # This workflow will build a Swift project # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift -name: Swift +name: ci on: push: @@ -15,9 +15,13 @@ jobs: fail-fast: false matrix: os: ["macos-13"] + swift: ["5.8"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 + - uses: swift-actions/setup-swift@v1 + with: + swift-version: ${{ matrix.swift }} - name: Build run: swift build -v - name: Run From 232bf94aff990d7a3409fcf12c7500f8e97e395f Mon Sep 17 00:00:00 2001 From: Jarrod Davis Date: Sat, 1 Jul 2023 15:51:40 -0600 Subject: [PATCH 03/15] Select Xcode Version --- .github/workflows/ci.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 578788b..9330f48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,13 +15,12 @@ jobs: fail-fast: false matrix: os: ["macos-13"] - swift: ["5.8"] + xcode: ["xcode_14.3.1", "xcode_15.0"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 - - uses: swift-actions/setup-swift@v1 - with: - swift-version: ${{ matrix.swift }} + - name: Select Xcode version + run: xcode-select --switch "/Applications/${{ matrix.xcode }}.app" - name: Build run: swift build -v - name: Run From d5baad94b4c6465ca786f811e90724df6c88b901 Mon Sep 17 00:00:00 2001 From: Jarrod Davis Date: Sat, 1 Jul 2023 15:52:50 -0600 Subject: [PATCH 04/15] sudo --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9330f48..204619e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Select Xcode version - run: xcode-select --switch "/Applications/${{ matrix.xcode }}.app" + run: sudo xcode-select --switch "/Applications/${{ matrix.xcode }}.app" - name: Build run: swift build -v - name: Run From 2e695b13a7ef7b16207fbda10a82be0d43dcd46c Mon Sep 17 00:00:00 2001 From: Jarrod Davis Date: Sat, 1 Jul 2023 16:30:08 -0600 Subject: [PATCH 05/15] Dynamic Debug Logging in Actions --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 204619e..f1eee9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,6 @@ jobs: - name: Select Xcode version run: sudo xcode-select --switch "/Applications/${{ matrix.xcode }}.app" - name: Build - run: swift build -v + run: swift build ${{ runner.debug && '-v' }} - name: Run - run: swift run -v + run: swift run ${{ runner.debug && '-v' }} DotFiles ${{ runner.debug && '-v' }} From 1d92a16be7b9b48b670bf130ddbc42bc490e84bd Mon Sep 17 00:00:00 2001 From: Jarrod Davis Date: Sat, 1 Jul 2023 21:03:49 -0600 Subject: [PATCH 06/15] Handle Non-Interactive Process --- Sources/Helpers/Logger+Error.swift | 6 +-- Sources/Helpers/Process.swift | 73 ++++++++++++++++++++++++++---- Sources/Steps/SudoSession.swift | 8 +++- 3 files changed, 72 insertions(+), 15 deletions(-) diff --git a/Sources/Helpers/Logger+Error.swift b/Sources/Helpers/Logger+Error.swift index 82e6297..b6e2bab 100644 --- a/Sources/Helpers/Logger+Error.swift +++ b/Sources/Helpers/Logger+Error.swift @@ -1,13 +1,13 @@ import Logging extension Logger { - func error( + func error( _ message: @autoclosure () -> Message, - error: Error, + error: T, file: String = #fileID, function: String = #function, line: UInt = #line - ) -> Error { + ) -> T { self.error(message(), metadata: ["error": "\(error)"], file: file, function: function, line: line) return error } diff --git a/Sources/Helpers/Process.swift b/Sources/Helpers/Process.swift index 3d8119d..00d80d9 100644 --- a/Sources/Helpers/Process.swift +++ b/Sources/Helpers/Process.swift @@ -3,6 +3,9 @@ import Logging import System private enum ProcessExecutorError: Error { + case currentProcessGroupProbeFailure(reason: Errno) + case failedToStart(reason: Error) + case quasiInteractive(controllingProcessGroupID: Int32, currentProcessGroupID: Int32) case unsuccessfulForegrounding(reason: Errno) case unsuccessfulTermination(reason: Process.TerminationReason, status: Int32) } @@ -83,24 +86,31 @@ struct ProcessExecutor { private static func execute(_ level: Logger.Level) async throws { let process = current! - async let complete = withCheckedContinuation { process.terminationHandler = $0.resume } + let isInteractive = try probeIsInteractive(level: level) logger.log(level: level, "starting process") do { try process.run() } catch { + let error = ProcessExecutorError.failedToStart(reason: error) throw logger.error("failed to start process", error: error) } - guard tcsetpgrp(STDIN_FILENO, process.processIdentifier) == 0 else { - let error = logger.error( - "failed to foreground process, attempting to terminate...", - error: ProcessExecutorError.unsuccessfulForegrounding(reason: Errno(rawValue: errno)) - ) - process.terminate() - let _ = await complete - logger.notice("process terminated") - throw error + async let complete = withCheckedContinuation { process.terminationHandler = $0.resume } + + if isInteractive { + logger.log(level: level, "attempting to foreground interactive process") + guard tcsetpgrp(STDIN_FILENO, process.processIdentifier) == 0 else { + let error = logger.error("failed to foreground process", error: Errno(rawValue: errno)) + logger.notice("attempting to terminate process") + process.terminate() + let _ = await complete + logger.notice("process terminated") + throw ProcessExecutorError.unsuccessfulForegrounding(reason: error) + } + logger.log(level: level, "process foregrounded successfully") + } else { + logger.log(level: level, "skipping foregrounding of non-interactive process") } let _ = await complete @@ -114,4 +124,47 @@ struct ProcessExecutor { throw logger.error("process terminated unsuccessfully", error: error) } } + + private static func probeIsInteractive(level: Logger.Level) throws -> Bool { + logger.log(level: level, "probing controlling process status") + + let currentProcessGroupID = getpgrp() + guard currentProcessGroupID != -1 else { + let error = ProcessExecutorError.currentProcessGroupProbeFailure(reason: Errno(rawValue: errno)) + throw logger.error("failed to probe for current process group", error: error) + } + + let controllingProcessGroupID = tcgetpgrp(STDIN_FILENO); + guard controllingProcessGroupID != -1 else { + logger.log( + level: level, + "could not probe for controlling process group", + metadata: [ + "reason": "\(Errno(rawValue: errno))" + ] + ) + return false + } + + if controllingProcessGroupID != currentProcessGroupID { + let error = ProcessExecutorError.quasiInteractive( + controllingProcessGroupID: controllingProcessGroupID, + currentProcessGroupID: currentProcessGroupID + ) + + throw logger.error("current process group is quasi-interactive", error: error) + } else { + logger.log( + level: level, + "current process group is controlling", + metadata: [ + "interactive": [ + "controllingProcessGroupID": "\(controllingProcessGroupID)", + "currentProcessGroupID": "\(currentProcessGroupID)", + ] + ] + ) + return true + } + } } diff --git a/Sources/Steps/SudoSession.swift b/Sources/Steps/SudoSession.swift index fa2855d..6ade553 100644 --- a/Sources/Steps/SudoSession.swift +++ b/Sources/Steps/SudoSession.swift @@ -29,15 +29,19 @@ struct SudoSession { try await $current.withValue(true) { logger.info("priming sudo keep-alive session") try await ProcessExecutor.execute(command: "/usr/bin/sudo", with: "-v") + logger.debug("sudo keep-alive session primed successfully") } let keepalive = Task { try await $current.withValue(true) { while !Task.isCancelled { try await Task.sleep(for: .seconds(60)) - logger.debug("continuing sudo keep-alive session") - try await ProcessExecutor.execute(command: "/usr/bin/sudo", with: "-vn", at: .debug) + logger.trace("continuing sudo keep-alive session") + try await ProcessExecutor.execute(command: "/usr/bin/sudo", with: "-vn", at: .trace) + logger.trace("sudo keep-alive session continued successfully") } + + logger.debug("sudo keep-alive session cancelled") } } From 651a8615084ef5377c9c28382897c8598fe5a2c5 Mon Sep 17 00:00:00 2001 From: Jarrod Davis Date: Sat, 1 Jul 2023 21:16:06 -0600 Subject: [PATCH 07/15] sudo? --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1eee9f..bd28dca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,4 +24,4 @@ jobs: - name: Build run: swift build ${{ runner.debug && '-v' }} - name: Run - run: swift run ${{ runner.debug && '-v' }} DotFiles ${{ runner.debug && '-v' }} + run: sudo swift run ${{ runner.debug && '-v' }} DotFiles ${{ runner.debug && '-v' }} From c004fae3ea83c202ee538f2e4a660f75ff647595 Mon Sep 17 00:00:00 2001 From: Jarrod Davis Date: Sat, 1 Jul 2023 21:18:21 -0600 Subject: [PATCH 08/15] which sudo --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd28dca..b8922dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,9 @@ jobs: - uses: actions/checkout@v3 - name: Select Xcode version run: sudo xcode-select --switch "/Applications/${{ matrix.xcode }}.app" + - name: which sudo + run: which -a sudo - name: Build run: swift build ${{ runner.debug && '-v' }} - name: Run - run: sudo swift run ${{ runner.debug && '-v' }} DotFiles ${{ runner.debug && '-v' }} + run: swift run ${{ runner.debug && '-v' }} DotFiles ${{ runner.debug && '-v' }} From 3af5212edd303bc916b9a29cc20fa9690eaa0eaf Mon Sep 17 00:00:00 2001 From: Jarrod Davis Date: Wed, 5 Jul 2023 21:59:26 -0600 Subject: [PATCH 09/15] sudo -ll --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8922dd..6c4189a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,8 +21,8 @@ jobs: - uses: actions/checkout@v3 - name: Select Xcode version run: sudo xcode-select --switch "/Applications/${{ matrix.xcode }}.app" - - name: which sudo - run: which -a sudo + - name: sudo -ll + run: sudo -ll - name: Build run: swift build ${{ runner.debug && '-v' }} - name: Run From d9cfd923a30e9f482b583334f32c94e0f7d9f62e Mon Sep 17 00:00:00 2001 From: Jarrod Davis Date: Wed, 5 Jul 2023 22:08:58 -0600 Subject: [PATCH 10/15] more sudo spelunking --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c4189a..ec20fc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,10 @@ jobs: - uses: actions/checkout@v3 - name: Select Xcode version run: sudo xcode-select --switch "/Applications/${{ matrix.xcode }}.app" + - name: cat sudo + run: sudo cat /etc/sudoers + - name: ls sudo + run: sudo ls -la /etc/sudoers.d - name: sudo -ll run: sudo -ll - name: Build From 4903cf1b4fe23cbb77977c25860953552a79e429 Mon Sep 17 00:00:00 2001 From: Jarrod Davis Date: Wed, 5 Jul 2023 22:25:59 -0600 Subject: [PATCH 11/15] more sudo spelunking --- .github/workflows/ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec20fc6..779777d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,9 +22,12 @@ jobs: - name: Select Xcode version run: sudo xcode-select --switch "/Applications/${{ matrix.xcode }}.app" - name: cat sudo - run: sudo cat /etc/sudoers - - name: ls sudo - run: sudo ls -la /etc/sudoers.d + run: | + sudo cat /etc/sudoers + echo '---' + sudo cat /etc/sudoers.d/anka + echo '---' + sudo cat /etc/sudoers.d/runner - name: sudo -ll run: sudo -ll - name: Build From 0c77b909824eee86af2f8dff5b1f13a6c57bb4a3 Mon Sep 17 00:00:00 2001 From: Jarrod Davis Date: Wed, 5 Jul 2023 22:39:50 -0600 Subject: [PATCH 12/15] verifypw --- .github/workflows/ci.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 779777d..cc82698 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,13 +21,10 @@ jobs: - uses: actions/checkout@v3 - name: Select Xcode version run: sudo xcode-select --switch "/Applications/${{ matrix.xcode }}.app" - - name: cat sudo - run: | - sudo cat /etc/sudoers - echo '---' - sudo cat /etc/sudoers.d/anka - echo '---' - sudo cat /etc/sudoers.d/runner + - name: Update sudo config + # Don't require password for `sudo -v` + # https://askubuntu.com/a/1211226/9812 + run: sudo sh -c 'echo "Defaults verifypw = any" >> /etc/sudoers.d/verifypw' - name: sudo -ll run: sudo -ll - name: Build From fa8d3a5e3a7665cb92d716c670a94a37e4a7f9b2 Mon Sep 17 00:00:00 2001 From: Jarrod Davis Date: Thu, 6 Jul 2023 02:25:06 -0600 Subject: [PATCH 13/15] flesh out interactivity probe --- .github/workflows/ci.yml | 4 +- Sources/Helpers/Interactivity.swift | 107 ++++++++++++++++++++++++++++ Sources/Helpers/Logger+Error.swift | 1 + Sources/Helpers/Process.swift | 84 ++++++++-------------- Sources/Helpers/VersionParser.swift | 6 +- Sources/Steps/SudoSession.swift | 1 + 6 files changed, 143 insertions(+), 60 deletions(-) create mode 100644 Sources/Helpers/Interactivity.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc82698..6e9db98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,7 @@ jobs: # Don't require password for `sudo -v` # https://askubuntu.com/a/1211226/9812 run: sudo sh -c 'echo "Defaults verifypw = any" >> /etc/sudoers.d/verifypw' - - name: sudo -ll - run: sudo -ll - name: Build run: swift build ${{ runner.debug && '-v' }} - name: Run - run: swift run ${{ runner.debug && '-v' }} DotFiles ${{ runner.debug && '-v' }} + run: swift run ${{ runner.debug && '-v' }} DotFiles ${{ runner.debug && '-vv' }} diff --git a/Sources/Helpers/Interactivity.swift b/Sources/Helpers/Interactivity.swift new file mode 100644 index 0000000..2133f67 --- /dev/null +++ b/Sources/Helpers/Interactivity.swift @@ -0,0 +1,107 @@ +import Foundation +import Logging +import System + +private enum InteractivityError: Error { + case unsuccessfulBackgrounding(reason: Errno) + case unsuccessfulForegrounding(reason: Errno) +} + +private func makesyscall(_ fn: (repeat each TArgs) -> Int32, _ args: repeat each TArgs) -> Result { + switch fn(repeat each args) { + case -1: .failure(Errno(rawValue: errno)) + case let result: .success(result) + } +} + +func background() throws { + logger.trace("attempting to background process") + switch makesyscall(setsid) { + case .failure(let error): + logger.error("failed to background process", error: error) + throw InteractivityError.unsuccessfulBackgrounding(reason: error) + case .success(let result): + logger.trace("process backgrounded successfully", metadata: ["sessionID": "\(result)"]) + } +} + +func foreground(pid: Int32) throws { + logger.trace("attempting to foreground process", metadata: ["pid": "\(pid)"]) + switch makesyscall(tcsetpgrp, STDIN_FILENO, pid) { + case .failure(let error): + logger.error("failed to foreground process", error: error) + throw InteractivityError.unsuccessfulForegrounding(reason: error) + case .success(let result): + logger.trace("process foregrounded successfully", metadata: ["result": "\(result)"]) + return + } +} + +struct InteractivityProber { + static func getStatus() -> InteractivityStatus { + let current = Self() + let status = current._status + logger.trace("probed interactivity status", metadata: [ + "interactivity": [ + "status": "\(status)", + "pid": "\(current.currentProcessID)", + "pgrp": "\(current.currentProcessGroupID)", + "sid": "\(current.currentSessionID)", + "tcpgrp": "\(current.controllingProcessGroupID)", + "tcsid": "\(current.controllingSessionID)", + ] + ]) + return status + } + + let currentProcessID = ProcessInfo.processInfo.processIdentifier + + let currentProcessGroupID = makesyscall(getpgrp) + let currentSessionID = makesyscall(getsid, 0) + + let controllingProcessGroupID = makesyscall(tcgetpgrp, STDIN_FILENO) + let controllingSessionID = makesyscall(tcgetsid, STDIN_FILENO) + + private var _status: InteractivityStatus { + switch (currentProcessGroupID, currentSessionID, controllingProcessGroupID, controllingSessionID) { + case (.failure(_), _, _, _), + (_, .failure(_), _, _): + return .unknown + + case (.success(let groupID), .success(let sessionID), .failure(_), _) + where groupID == sessionID, + (.success(let groupID), .success(let sessionID), _, .failure(_)) + where groupID == sessionID: + return .backgroundLeader + + case (.success(_), .success(_), .failure(_), _), + (.success(_), .success(_), _, .failure(_)): + return .backgroundFollower + + case (.success(let current), .success(_), .success(let controlling), .success(_)) + where current == controlling: + return .interactiveLeader + + case (.success(_), .success(let current), .success(_), .success(let controlling)) + where current == controlling: + return .interactiveFollower + + case (.success(let groupID), .success(let sessionID), .success(_), .success(_)) + where groupID == sessionID: + return .backgroundLeader + + case (.success(_), .success(_), .success(_), .success(_)): + return .backgroundFollower + } + } + + private init() {} +} + +enum InteractivityStatus { + case interactiveFollower + case interactiveLeader + case backgroundFollower + case backgroundLeader + case unknown +} diff --git a/Sources/Helpers/Logger+Error.swift b/Sources/Helpers/Logger+Error.swift index b6e2bab..658f1e7 100644 --- a/Sources/Helpers/Logger+Error.swift +++ b/Sources/Helpers/Logger+Error.swift @@ -1,6 +1,7 @@ import Logging extension Logger { + @discardableResult func error( _ message: @autoclosure () -> Message, error: T, diff --git a/Sources/Helpers/Process.swift b/Sources/Helpers/Process.swift index 00d80d9..98c9df2 100644 --- a/Sources/Helpers/Process.swift +++ b/Sources/Helpers/Process.swift @@ -3,10 +3,8 @@ import Logging import System private enum ProcessExecutorError: Error { - case currentProcessGroupProbeFailure(reason: Errno) + case unsuccessfulInteractivityProbe case failedToStart(reason: Error) - case quasiInteractive(controllingProcessGroupID: Int32, currentProcessGroupID: Int32) - case unsuccessfulForegrounding(reason: Errno) case unsuccessfulTermination(reason: Process.TerminationReason, status: Int32) } @@ -86,7 +84,16 @@ struct ProcessExecutor { private static func execute(_ level: Logger.Level) async throws { let process = current! - let isInteractive = try probeIsInteractive(level: level) + let interactivityStatus = InteractivityProber.getStatus() + + guard interactivityStatus != .unknown else { + logger.error("failed to probe interactivity status") + throw ProcessExecutorError.unsuccessfulInteractivityProbe + } + + if interactivityStatus == .backgroundFollower { + try background() + } logger.log(level: level, "starting process") do { @@ -95,22 +102,34 @@ struct ProcessExecutor { let error = ProcessExecutorError.failedToStart(reason: error) throw logger.error("failed to start process", error: error) } + logger.log(level: min(.debug, level), "started process successfully") async let complete = withCheckedContinuation { process.terminationHandler = $0.resume } - if isInteractive { - logger.log(level: level, "attempting to foreground interactive process") - guard tcsetpgrp(STDIN_FILENO, process.processIdentifier) == 0 else { - let error = logger.error("failed to foreground process", error: Errno(rawValue: errno)) + if interactivityStatus == .interactiveLeader { + do { + try foreground(pid: process.processIdentifier) + } catch { logger.notice("attempting to terminate process") process.terminate() let _ = await complete logger.notice("process terminated") - throw ProcessExecutorError.unsuccessfulForegrounding(reason: error) + throw error } - logger.log(level: level, "process foregrounded successfully") } else { - logger.log(level: level, "skipping foregrounding of non-interactive process") + logger.log(level: min(.debug, level), "skipping foregrounding of non-interactive or non-leader process") + } + + defer { + if interactivityStatus == .interactiveLeader { + do { + logger.trace("restoring foreground process") + try foreground(pid: ProcessInfo.processInfo.processIdentifier) + logger.trace("foreground process restored successfully") + } catch { + logger.warning("failed to restore foreground process", metadata: ["reason": "\(error)"]) + } + } } let _ = await complete @@ -124,47 +143,4 @@ struct ProcessExecutor { throw logger.error("process terminated unsuccessfully", error: error) } } - - private static func probeIsInteractive(level: Logger.Level) throws -> Bool { - logger.log(level: level, "probing controlling process status") - - let currentProcessGroupID = getpgrp() - guard currentProcessGroupID != -1 else { - let error = ProcessExecutorError.currentProcessGroupProbeFailure(reason: Errno(rawValue: errno)) - throw logger.error("failed to probe for current process group", error: error) - } - - let controllingProcessGroupID = tcgetpgrp(STDIN_FILENO); - guard controllingProcessGroupID != -1 else { - logger.log( - level: level, - "could not probe for controlling process group", - metadata: [ - "reason": "\(Errno(rawValue: errno))" - ] - ) - return false - } - - if controllingProcessGroupID != currentProcessGroupID { - let error = ProcessExecutorError.quasiInteractive( - controllingProcessGroupID: controllingProcessGroupID, - currentProcessGroupID: currentProcessGroupID - ) - - throw logger.error("current process group is quasi-interactive", error: error) - } else { - logger.log( - level: level, - "current process group is controlling", - metadata: [ - "interactive": [ - "controllingProcessGroupID": "\(controllingProcessGroupID)", - "currentProcessGroupID": "\(currentProcessGroupID)", - ] - ] - ) - return true - } - } } diff --git a/Sources/Helpers/VersionParser.swift b/Sources/Helpers/VersionParser.swift index 3b537a7..7a7d98a 100644 --- a/Sources/Helpers/VersionParser.swift +++ b/Sources/Helpers/VersionParser.swift @@ -46,15 +46,15 @@ struct VersionParser: CustomConsumingRegexComponent { private static func parse() -> ParserResult { let args = current! - logger.debug("parsing version from input") + logger.trace("parsing version from input") if let version = Version(args.versionString) { - logger.debug("parsed full version string") + logger.trace("parsed full version string") return (args.bounds.upperBound, version) } if let version = Version("\(args.versionString).0") { - logger.debug("parsed partial (non-patch) version string") + logger.trace("parsed partial (non-patch) version string") return (args.bounds.upperBound, version) } diff --git a/Sources/Steps/SudoSession.swift b/Sources/Steps/SudoSession.swift index 6ade553..30f1b1a 100644 --- a/Sources/Steps/SudoSession.swift +++ b/Sources/Steps/SudoSession.swift @@ -28,6 +28,7 @@ struct SudoSession { static func start() async throws -> Self { try await $current.withValue(true) { logger.info("priming sudo keep-alive session") + // TODO: require leadership for interactive execution to prevent quasi-interactive input stall try await ProcessExecutor.execute(command: "/usr/bin/sudo", with: "-v") logger.debug("sudo keep-alive session primed successfully") } From b0724aeb9b33e5026cdbaeb73ef5fd97f1c0aa73 Mon Sep 17 00:00:00 2001 From: Jarrod Davis Date: Sat, 8 Jul 2023 02:45:32 -0600 Subject: [PATCH 14/15] Refactor Leadership Promotion --- Sources/DotFiles.swift | 25 ++- Sources/Helpers/ExecutionSession.swift | 283 +++++++++++++++++++++++++ Sources/Helpers/Interactivity.swift | 107 ---------- Sources/Helpers/Process.swift | 62 ++---- Sources/Steps/SudoSession.swift | 2 +- 5 files changed, 317 insertions(+), 162 deletions(-) create mode 100644 Sources/Helpers/ExecutionSession.swift delete mode 100644 Sources/Helpers/Interactivity.swift diff --git a/Sources/DotFiles.swift b/Sources/DotFiles.swift index 0d727a4..0351ac6 100644 --- a/Sources/DotFiles.swift +++ b/Sources/DotFiles.swift @@ -44,6 +44,7 @@ struct DotFiles: AsyncParsableCommand { LoggingSystem.bootstrap( StreamLogHandler.standardOutput, metadataProvider: .multiplex([ + ExecutionSession.metadataProvider, LinkCreator.metadataProvider, ProcessExecutor.metadataProvider, RemoteScriptRunner.metadataProvider, @@ -65,17 +66,19 @@ struct DotFiles: AsyncParsableCommand { break } - let sudoSession = try await SudoSession.start() - - try await XcodeToolsInstaller.install() - try await RepositoryCloner.clone(from: remote, to: local) - - try LinkCreator.create { - local / "zshrc" <- ".zshrc" + try await ExecutionSession.main { + let sudoSession = try await SudoSession.start() + + try await XcodeToolsInstaller.install() + try await RepositoryCloner.clone(from: remote, to: local) + + try LinkCreator.create { + local / "zshrc" <- ".zshrc" + } + + try await RemoteScriptRunner.run(.homebrewInstaller, using: .bash, with: ["CI": "true"]) + + try await sudoSession.finish() } - - try await RemoteScriptRunner.run(.homebrewInstaller, using: .bash, with: ["CI": "true"]) - - try await sudoSession.finish() } } diff --git a/Sources/Helpers/ExecutionSession.swift b/Sources/Helpers/ExecutionSession.swift new file mode 100644 index 0000000..907c26e --- /dev/null +++ b/Sources/Helpers/ExecutionSession.swift @@ -0,0 +1,283 @@ +import Foundation +import Logging +import System + +private func makesyscall( + _ fn: (repeat each TArgs) -> Int32, + _ args: repeat each TArgs +) -> Result { + switch fn(repeat each args) { + case -1: .failure(Errno(rawValue: errno)) + case let result: .success(result) + } +} + +enum ExecutionSessionError: Error { + case mismatchedBackgrounding(expected: Int32, actual: Int32) + case mismatchedForegrounding(expected: Int32, actual: Int32) + case unsuccessfulBackgrounding(reason: Errno) + case unsuccessfulForegrounding(reason: Errno) + case unsuccessfulInteractivityProbe +} + +enum InteractivityStatus { + case backgroundFollower(groupID: Int32, sessionID: Int32) + case backgroundLeader(groupID: Int32, sessionID: Int32) + case foregroundFollower(groupID: Int32, sessionID: Int32) + case foregroundLeader(groupID: Int32, sessionID: Int32) + case unknown +} + +class InteractivityState { + private var controllingProcessGroupID: Result + private var controllingSessionID: Result + + private let targetProcessID: Int32 + private var targetProcessGroupID: Result + private var targetSessionID: Result + + var targetStatus: InteractivityStatus { + resolveStatus( + targetProcessGroupID: targetProcessGroupID, + targetSessionID: targetSessionID, + controllingProcessGroupID: controllingProcessGroupID, + controllingSessionID: controllingSessionID + ) + } + + private let currentProcessID: Int32 + private var currentProcessGroupID: Result + private var currentSessionID: Result + + var currentStatus: InteractivityStatus { + resolveStatus( + targetProcessGroupID: currentProcessGroupID, + targetSessionID: currentSessionID, + controllingProcessGroupID: controllingProcessGroupID, + controllingSessionID: controllingSessionID + ) + } + + var metadata: Logger.MetadataValue { + if targetProcessID == currentProcessID { + [ + "tcpgrp": "\(controllingProcessGroupID)", + "tcsid": "\(controllingSessionID)", + "pid": "\(currentProcessID)", + "pgrp": "\(currentProcessGroupID)", + "sid": "\(currentSessionID)", + "status": "\(currentStatus)", + ] + } else { + [ + "tcpgrp": "\(controllingProcessGroupID)", + "tcsid": "\(controllingSessionID)", + "current": [ + "pid": "\(currentProcessID)", + "pgrp": "\(currentProcessGroupID)", + "sid": "\(currentSessionID)", + "status": "\(currentStatus)", + ], + "target": [ + "pid": "\(targetProcessID)", + "pgrp": "\(targetProcessGroupID)", + "sid": "\(targetSessionID)", + "status": "\(targetStatus)", + ], + ] + } + } + + init(pid: Int32) { + targetProcessID = pid + currentProcessID = ProcessInfo.processInfo.processIdentifier + + // TODO: Is there a way to init using `refresh()`? + controllingProcessGroupID = makesyscall(tcgetpgrp, STDIN_FILENO) + controllingSessionID = makesyscall(tcgetsid, STDIN_FILENO) + targetProcessGroupID = makesyscall(getpgid, targetProcessID) + targetSessionID = makesyscall(getsid, targetProcessID) + currentProcessGroupID = makesyscall(getpgrp) + currentSessionID = makesyscall(getsid, 0) + } + + func refresh() { + controllingProcessGroupID = makesyscall(tcgetpgrp, STDIN_FILENO) + controllingSessionID = makesyscall(tcgetsid, STDIN_FILENO) + targetProcessGroupID = makesyscall(getpgid, targetProcessID) + targetSessionID = makesyscall(getsid, targetProcessID) + currentProcessGroupID = makesyscall(getpgrp) + currentSessionID = makesyscall(getsid, 0) + } + + func promote() throws { + switch targetStatus { + case .backgroundFollower(let groupID, _): + guard self.currentProcessID == self.targetProcessID else { + logger.trace("maintaining background follower") + return + } + + logger.trace("promoting background follower to leader") + + let result = switch makesyscall(setsid) { + case .failure(let error): + logger.error("`setsid` failed", error: error) + throw ExecutionSessionError.unsuccessfulBackgrounding(reason: error) + case .success(let result): + result + } + + logger.trace("`setsid` completed", metadata: ["result": "\(result)"]) + + self.refresh() + + guard case .backgroundLeader = self.targetStatus else { + switch self.targetSessionID { + case .success(let sessionID): + logger.error("process promotion failed despite `setsid` success") + throw ExecutionSessionError.mismatchedBackgrounding(expected: groupID, actual: sessionID) + case .failure(let error): + logger.error("failed to verify process promotion", error: error) + throw ExecutionSessionError.unsuccessfulBackgrounding(reason: error) + } + } + + logger.trace("process promoted successfully") + + case .backgroundLeader: + logger.trace("maintaining background leader") + + case .foregroundFollower(let groupID, _): + guard self.currentProcessID == self.targetProcessID || self.currentProcessGroupID == self.controllingProcessGroupID else { + // TODO: Make this an error by default and allow opting into refusal ("sidecar" execution). + logger.trace("refusing to steal leadership from other foreground child process") + return + } + + logger.trace("promoting foreground follower to leader") + + let result = switch makesyscall(tcsetpgrp, STDIN_FILENO, groupID) { + case .success(let result): + result + case .failure(let error): + logger.error("`tcsetpgrp` failed", error: error) + throw ExecutionSessionError.unsuccessfulForegrounding(reason: error) + } + + logger.trace("`tcsetpgrp` completed", metadata: ["result": "\(result)"]) + + self.refresh() + + guard case .foregroundLeader = self.targetStatus else { + switch self.controllingProcessGroupID { + case .success(let controllingGroupID): + logger.error("process promotion failed despite `tcsetpgrp` success") + throw ExecutionSessionError.mismatchedForegrounding(expected: groupID, actual: controllingGroupID) + case .failure(let error): + logger.error("failed to verify process promotion", error: error) + throw ExecutionSessionError.unsuccessfulForegrounding(reason: error) + } + } + + logger.trace("process promoted successfully") + + case .foregroundLeader: + logger.trace("maintaining foreground leader") + + case .unknown: + logger.error("failed to probe interactivity status") + throw ExecutionSessionError.unsuccessfulInteractivityProbe + } + } +} + +private func resolveStatus( + targetProcessGroupID: Result, + targetSessionID: Result, + controllingProcessGroupID: Result, + controllingSessionID: Result +) -> InteractivityStatus { + switch (targetProcessGroupID, targetSessionID, controllingProcessGroupID, controllingSessionID) { + case (.failure(_), _, _, _), + (_, .failure(_), _, _): + return .unknown + + case (.success(let groupID), .success(let sessionID), .failure(_), _) + where groupID == sessionID, + (.success(let groupID), .success(let sessionID), _, .failure(_)) + where groupID == sessionID: + return .backgroundLeader(groupID: groupID, sessionID: sessionID) + + case (.success(let groupID), .success(let sessionID), .failure(_), _), + (.success(let groupID), .success(let sessionID), _, .failure(_)): + return .backgroundFollower(groupID: groupID, sessionID: sessionID) + + case (.success(let target), .success(let sessionID), .success(let controlling), .success(_)) + where target == controlling: + return .foregroundLeader(groupID: target, sessionID: sessionID) + + case (.success(let groupID), .success(let target), .success(_), .success(let controlling)) + where target == controlling: + return .foregroundFollower(groupID: groupID, sessionID: target) + + case (.success(let groupID), .success(let sessionID), .success(_), .success(_)) + where groupID == sessionID: + return .backgroundLeader(groupID: groupID, sessionID: sessionID) + + case (.success(let groupID), .success(let sessionID), .success(_), .success(_)): + return .backgroundFollower(groupID: groupID, sessionID: sessionID) + } +} + +struct ExecutionSession { + @TaskLocal private static var current: InteractivityState? + + static let metadataProvider = Logger.MetadataProvider { + guard let current else { return [:] } + return ["interactivity": current.metadata] + } + + static func main(operation: () async throws -> Void) async throws { + logger.trace("starting main execution session") + + let state = InteractivityState(pid: ProcessInfo.processInfo.processIdentifier) + + try $current.withValue(state) { + try state.promote() + } + + try await $current.withValue(state, operation: operation) + } + + static func child( + pid: Int32, + operation: () async throws -> Void, + onPromotionFailed: () async throws -> Void + ) async throws { + let parent = current! + + logger.trace("starting child execution session") + + let state = InteractivityState(pid: pid) + + try $current.withValue(state) { + try state.promote() + parent.refresh() + } + + do { + try await $current.withValue(state, operation: operation) + } catch { + parent.refresh() + logger.error("child execution session failed", error: error) + logger.trace("restoring previous leader") + try parent.promote() + throw error + } + + parent.refresh() + logger.trace("child execution session completed, restoring previous leader") + try parent.promote() + } +} diff --git a/Sources/Helpers/Interactivity.swift b/Sources/Helpers/Interactivity.swift deleted file mode 100644 index 2133f67..0000000 --- a/Sources/Helpers/Interactivity.swift +++ /dev/null @@ -1,107 +0,0 @@ -import Foundation -import Logging -import System - -private enum InteractivityError: Error { - case unsuccessfulBackgrounding(reason: Errno) - case unsuccessfulForegrounding(reason: Errno) -} - -private func makesyscall(_ fn: (repeat each TArgs) -> Int32, _ args: repeat each TArgs) -> Result { - switch fn(repeat each args) { - case -1: .failure(Errno(rawValue: errno)) - case let result: .success(result) - } -} - -func background() throws { - logger.trace("attempting to background process") - switch makesyscall(setsid) { - case .failure(let error): - logger.error("failed to background process", error: error) - throw InteractivityError.unsuccessfulBackgrounding(reason: error) - case .success(let result): - logger.trace("process backgrounded successfully", metadata: ["sessionID": "\(result)"]) - } -} - -func foreground(pid: Int32) throws { - logger.trace("attempting to foreground process", metadata: ["pid": "\(pid)"]) - switch makesyscall(tcsetpgrp, STDIN_FILENO, pid) { - case .failure(let error): - logger.error("failed to foreground process", error: error) - throw InteractivityError.unsuccessfulForegrounding(reason: error) - case .success(let result): - logger.trace("process foregrounded successfully", metadata: ["result": "\(result)"]) - return - } -} - -struct InteractivityProber { - static func getStatus() -> InteractivityStatus { - let current = Self() - let status = current._status - logger.trace("probed interactivity status", metadata: [ - "interactivity": [ - "status": "\(status)", - "pid": "\(current.currentProcessID)", - "pgrp": "\(current.currentProcessGroupID)", - "sid": "\(current.currentSessionID)", - "tcpgrp": "\(current.controllingProcessGroupID)", - "tcsid": "\(current.controllingSessionID)", - ] - ]) - return status - } - - let currentProcessID = ProcessInfo.processInfo.processIdentifier - - let currentProcessGroupID = makesyscall(getpgrp) - let currentSessionID = makesyscall(getsid, 0) - - let controllingProcessGroupID = makesyscall(tcgetpgrp, STDIN_FILENO) - let controllingSessionID = makesyscall(tcgetsid, STDIN_FILENO) - - private var _status: InteractivityStatus { - switch (currentProcessGroupID, currentSessionID, controllingProcessGroupID, controllingSessionID) { - case (.failure(_), _, _, _), - (_, .failure(_), _, _): - return .unknown - - case (.success(let groupID), .success(let sessionID), .failure(_), _) - where groupID == sessionID, - (.success(let groupID), .success(let sessionID), _, .failure(_)) - where groupID == sessionID: - return .backgroundLeader - - case (.success(_), .success(_), .failure(_), _), - (.success(_), .success(_), _, .failure(_)): - return .backgroundFollower - - case (.success(let current), .success(_), .success(let controlling), .success(_)) - where current == controlling: - return .interactiveLeader - - case (.success(_), .success(let current), .success(_), .success(let controlling)) - where current == controlling: - return .interactiveFollower - - case (.success(let groupID), .success(let sessionID), .success(_), .success(_)) - where groupID == sessionID: - return .backgroundLeader - - case (.success(_), .success(_), .success(_), .success(_)): - return .backgroundFollower - } - } - - private init() {} -} - -enum InteractivityStatus { - case interactiveFollower - case interactiveLeader - case backgroundFollower - case backgroundLeader - case unknown -} diff --git a/Sources/Helpers/Process.swift b/Sources/Helpers/Process.swift index 98c9df2..86da948 100644 --- a/Sources/Helpers/Process.swift +++ b/Sources/Helpers/Process.swift @@ -84,17 +84,6 @@ struct ProcessExecutor { private static func execute(_ level: Logger.Level) async throws { let process = current! - let interactivityStatus = InteractivityProber.getStatus() - - guard interactivityStatus != .unknown else { - logger.error("failed to probe interactivity status") - throw ProcessExecutorError.unsuccessfulInteractivityProbe - } - - if interactivityStatus == .backgroundFollower { - try background() - } - logger.log(level: level, "starting process") do { try process.run() @@ -104,43 +93,30 @@ struct ProcessExecutor { } logger.log(level: min(.debug, level), "started process successfully") - async let complete = withCheckedContinuation { process.terminationHandler = $0.resume } - - if interactivityStatus == .interactiveLeader { - do { - try foreground(pid: process.processIdentifier) - } catch { - logger.notice("attempting to terminate process") - process.terminate() - let _ = await complete - logger.notice("process terminated") - throw error + try await ExecutionSession.child(pid: process.processIdentifier) { + let _ = await withCheckedContinuation { + process.terminationHandler = $0.resume } - } else { - logger.log(level: min(.debug, level), "skipping foregrounding of non-interactive or non-leader process") - } - defer { - if interactivityStatus == .interactiveLeader { - do { - logger.trace("restoring foreground process") - try foreground(pid: ProcessInfo.processInfo.processIdentifier) - logger.trace("foreground process restored successfully") - } catch { - logger.warning("failed to restore foreground process", metadata: ["reason": "\(error)"]) - } + logger.log(level: level, "process finished") + + let reason = process.terminationReason + let status = process.terminationStatus + + guard reason == .exit, status == 0 else { + let error = ProcessExecutorError.unsuccessfulTermination(reason: reason, status: status) + throw logger.error("process terminated unsuccessfully", error: error) } - } + } onPromotionFailed: { + logger.notice("attempting to terminate process") - let _ = await complete - logger.log(level: level, "process finished") - - let reason = process.terminationReason - let status = process.terminationStatus + async let complete = await withCheckedContinuation { + process.terminationHandler = $0.resume + } - guard reason == .exit, status == 0 else { - let error = ProcessExecutorError.unsuccessfulTermination(reason: reason, status: status) - throw logger.error("process terminated unsuccessfully", error: error) + process.terminate() + let _ = await complete + logger.notice("process terminated") } } } diff --git a/Sources/Steps/SudoSession.swift b/Sources/Steps/SudoSession.swift index 30f1b1a..ec8f82b 100644 --- a/Sources/Steps/SudoSession.swift +++ b/Sources/Steps/SudoSession.swift @@ -28,7 +28,6 @@ struct SudoSession { static func start() async throws -> Self { try await $current.withValue(true) { logger.info("priming sudo keep-alive session") - // TODO: require leadership for interactive execution to prevent quasi-interactive input stall try await ProcessExecutor.execute(command: "/usr/bin/sudo", with: "-v") logger.debug("sudo keep-alive session primed successfully") } @@ -38,6 +37,7 @@ struct SudoSession { while !Task.isCancelled { try await Task.sleep(for: .seconds(60)) logger.trace("continuing sudo keep-alive session") + // TODO: Make "sidecar" execution to prevent leader stealing. try await ProcessExecutor.execute(command: "/usr/bin/sudo", with: "-vn", at: .trace) logger.trace("sudo keep-alive session continued successfully") } From 64af6d9a80296d56787e216ca8dd5edbf8d04bae Mon Sep 17 00:00:00 2001 From: Jarrod Davis Date: Sat, 8 Jul 2023 22:21:56 -0600 Subject: [PATCH 15/15] clean up --- .github/workflows/ci.yml | 2 +- Sources/DotFiles.swift | 8 +- Sources/Helpers/ExecutionSession.swift | 140 +++++++++++++------------ Sources/Helpers/Process.swift | 4 +- 4 files changed, 81 insertions(+), 73 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e9db98..e90d047 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: fail-fast: false matrix: os: ["macos-13"] - xcode: ["xcode_14.3.1", "xcode_15.0"] + xcode: ["xcode_15.0"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 diff --git a/Sources/DotFiles.swift b/Sources/DotFiles.swift index 0351ac6..c975041 100644 --- a/Sources/DotFiles.swift +++ b/Sources/DotFiles.swift @@ -68,16 +68,16 @@ struct DotFiles: AsyncParsableCommand { try await ExecutionSession.main { let sudoSession = try await SudoSession.start() - + try await XcodeToolsInstaller.install() try await RepositoryCloner.clone(from: remote, to: local) - + try LinkCreator.create { local / "zshrc" <- ".zshrc" } - + try await RemoteScriptRunner.run(.homebrewInstaller, using: .bash, with: ["CI": "true"]) - + try await sudoSession.finish() } } diff --git a/Sources/Helpers/ExecutionSession.swift b/Sources/Helpers/ExecutionSession.swift index 907c26e..f998675 100644 --- a/Sources/Helpers/ExecutionSession.swift +++ b/Sources/Helpers/ExecutionSession.swift @@ -21,10 +21,10 @@ enum ExecutionSessionError: Error { } enum InteractivityStatus { - case backgroundFollower(groupID: Int32, sessionID: Int32) - case backgroundLeader(groupID: Int32, sessionID: Int32) - case foregroundFollower(groupID: Int32, sessionID: Int32) - case foregroundLeader(groupID: Int32, sessionID: Int32) + case backgroundFollower(groupID: Int32) + case backgroundLeader(groupID: Int32) + case foregroundFollower(groupID: Int32) + case foregroundLeader(groupID: Int32) case unknown } @@ -112,83 +112,91 @@ class InteractivityState { func promote() throws { switch targetStatus { - case .backgroundFollower(let groupID, _): + case .backgroundFollower(let groupID): guard self.currentProcessID == self.targetProcessID else { logger.trace("maintaining background follower") return } - logger.trace("promoting background follower to leader") - - let result = switch makesyscall(setsid) { - case .failure(let error): - logger.error("`setsid` failed", error: error) - throw ExecutionSessionError.unsuccessfulBackgrounding(reason: error) - case .success(let result): - result - } - - logger.trace("`setsid` completed", metadata: ["result": "\(result)"]) - - self.refresh() - - guard case .backgroundLeader = self.targetStatus else { - switch self.targetSessionID { - case .success(let sessionID): - logger.error("process promotion failed despite `setsid` success") - throw ExecutionSessionError.mismatchedBackgrounding(expected: groupID, actual: sessionID) - case .failure(let error): - logger.error("failed to verify process promotion", error: error) - throw ExecutionSessionError.unsuccessfulBackgrounding(reason: error) - } - } - - logger.trace("process promoted successfully") + try promoteBackground(groupID: groupID) case .backgroundLeader: logger.trace("maintaining background leader") - case .foregroundFollower(let groupID, _): + case .foregroundFollower(let groupID): guard self.currentProcessID == self.targetProcessID || self.currentProcessGroupID == self.controllingProcessGroupID else { // TODO: Make this an error by default and allow opting into refusal ("sidecar" execution). logger.trace("refusing to steal leadership from other foreground child process") return } - logger.trace("promoting foreground follower to leader") + try promoteForeground(groupID: groupID) + + case .foregroundLeader: + logger.trace("maintaining foreground leader") + + case .unknown: + logger.error("failed to probe interactivity status") + throw ExecutionSessionError.unsuccessfulInteractivityProbe + } + } + + private func promoteBackground(groupID: Int32) throws { + logger.trace("promoting background follower to leader") - let result = switch makesyscall(tcsetpgrp, STDIN_FILENO, groupID) { - case .success(let result): - result + let result = switch makesyscall(setsid) { + case .failure(let error): + logger.error("`setsid` failed", error: error) + throw ExecutionSessionError.unsuccessfulBackgrounding(reason: error) + case .success(let result): + result + } + + logger.trace("`setsid` completed", metadata: ["result": "\(result)"]) + + self.refresh() + + guard case .backgroundLeader = self.targetStatus else { + switch self.targetSessionID { + case .success(let sessionID): + logger.error("process promotion failed despite `setsid` success") + throw ExecutionSessionError.mismatchedBackgrounding(expected: groupID, actual: sessionID) case .failure(let error): - logger.error("`tcsetpgrp` failed", error: error) - throw ExecutionSessionError.unsuccessfulForegrounding(reason: error) + logger.error("failed to verify process promotion", error: error) + throw ExecutionSessionError.unsuccessfulBackgrounding(reason: error) } + } - logger.trace("`tcsetpgrp` completed", metadata: ["result": "\(result)"]) + logger.trace("process promoted successfully") + } - self.refresh() + private func promoteForeground(groupID: Int32) throws { + logger.trace("promoting foreground follower to leader") - guard case .foregroundLeader = self.targetStatus else { - switch self.controllingProcessGroupID { - case .success(let controllingGroupID): - logger.error("process promotion failed despite `tcsetpgrp` success") - throw ExecutionSessionError.mismatchedForegrounding(expected: groupID, actual: controllingGroupID) - case .failure(let error): - logger.error("failed to verify process promotion", error: error) - throw ExecutionSessionError.unsuccessfulForegrounding(reason: error) - } - } + let result = switch makesyscall(tcsetpgrp, STDIN_FILENO, groupID) { + case .success(let result): + result + case .failure(let error): + logger.error("`tcsetpgrp` failed", error: error) + throw ExecutionSessionError.unsuccessfulForegrounding(reason: error) + } - logger.trace("process promoted successfully") + logger.trace("`tcsetpgrp` completed", metadata: ["result": "\(result)"]) - case .foregroundLeader: - logger.trace("maintaining foreground leader") + self.refresh() - case .unknown: - logger.error("failed to probe interactivity status") - throw ExecutionSessionError.unsuccessfulInteractivityProbe + guard case .foregroundLeader = self.targetStatus else { + switch self.controllingProcessGroupID { + case .success(let controllingGroupID): + logger.error("process promotion failed despite `tcsetpgrp` success") + throw ExecutionSessionError.mismatchedForegrounding(expected: groupID, actual: controllingGroupID) + case .failure(let error): + logger.error("failed to verify process promotion", error: error) + throw ExecutionSessionError.unsuccessfulForegrounding(reason: error) + } } + + logger.trace("process promoted successfully") } } @@ -207,26 +215,26 @@ private func resolveStatus( where groupID == sessionID, (.success(let groupID), .success(let sessionID), _, .failure(_)) where groupID == sessionID: - return .backgroundLeader(groupID: groupID, sessionID: sessionID) + return .backgroundLeader(groupID: groupID) - case (.success(let groupID), .success(let sessionID), .failure(_), _), - (.success(let groupID), .success(let sessionID), _, .failure(_)): - return .backgroundFollower(groupID: groupID, sessionID: sessionID) + case (.success(let groupID), .success(_), .failure(_), _), + (.success(let groupID), .success(_), _, .failure(_)): + return .backgroundFollower(groupID: groupID) - case (.success(let target), .success(let sessionID), .success(let controlling), .success(_)) + case (.success(let target), .success(_), .success(let controlling), .success(_)) where target == controlling: - return .foregroundLeader(groupID: target, sessionID: sessionID) + return .foregroundLeader(groupID: target) case (.success(let groupID), .success(let target), .success(_), .success(let controlling)) where target == controlling: - return .foregroundFollower(groupID: groupID, sessionID: target) + return .foregroundFollower(groupID: groupID) case (.success(let groupID), .success(let sessionID), .success(_), .success(_)) where groupID == sessionID: - return .backgroundLeader(groupID: groupID, sessionID: sessionID) + return .backgroundLeader(groupID: groupID) - case (.success(let groupID), .success(let sessionID), .success(_), .success(_)): - return .backgroundFollower(groupID: groupID, sessionID: sessionID) + case (.success(let groupID), .success(_), .success(_), .success(_)): + return .backgroundFollower(groupID: groupID) } } diff --git a/Sources/Helpers/Process.swift b/Sources/Helpers/Process.swift index 86da948..affe0a0 100644 --- a/Sources/Helpers/Process.swift +++ b/Sources/Helpers/Process.swift @@ -99,10 +99,10 @@ struct ProcessExecutor { } logger.log(level: level, "process finished") - + let reason = process.terminationReason let status = process.terminationStatus - + guard reason == .exit, status == 0 else { let error = ProcessExecutorError.unsuccessfulTermination(reason: reason, status: status) throw logger.error("process terminated unsuccessfully", error: error)