From e405d520865a3e028a3a7e0a3c0e6109f9534df3 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Fri, 11 Sep 2026 18:26:42 -0700 Subject: [PATCH 1/4] terminal: add a regression test for the signals a spawned command can receive Red on this commit. It blocks SIGTERM on the calling thread, which is the state a libdispatch worker spawns in, runs an upload command that traps SIGTERM and records that it arrived, and checks the record exists. Today it does not: a signal mask survives exec, so the command is handed ours and never sees the signal. --- cmuxTests/TerminalUploadCommandTests.swift | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/cmuxTests/TerminalUploadCommandTests.swift b/cmuxTests/TerminalUploadCommandTests.swift index 54c9f652f40a..d8eeb51337a2 100644 --- a/cmuxTests/TerminalUploadCommandTests.swift +++ b/cmuxTests/TerminalUploadCommandTests.swift @@ -326,4 +326,41 @@ import Testing } #expect(text == "done") } + + @Test func realProcessDoesNotHandTheCommandOurBlockedSignals() { + // cmux spawns upload commands from a libdispatch worker, and those threads run + // with most signals blocked. A mask survives exec, so a command spawned without + // SETSIGMASK inherits it. Blocking SIGTERM here stands in for that worker: the + // command traps SIGTERM, signals itself, and records that the signal arrived, + // which it can only do if the mask did not come along. + // + // The command raises its own signal and then runs to completion, so nothing + // here waits on a clock and teardown never gets involved. A SIGTERM that was + // handed a blocked mask stays pending, is never delivered, and the marker is + // simply absent when the command exits. + var blocked = sigset_t() + sigemptyset(&blocked) + sigaddset(&blocked, SIGTERM) + var previous = sigset_t() + pthread_sigmask(SIG_BLOCK, &blocked, &previous) + defer { pthread_sigmask(SIG_SETMASK, &previous, nil) } + + let markerPath = NSTemporaryDirectory() + "cmux-upload-sigmask-\(UUID().uuidString)" + defer { try? FileManager.default.removeItem(atPath: markerPath) } + + let result = TerminalCustomUploadRunner().runSync( + fileURLs: [URL(fileURLWithPath: "/tmp/a.png")], + endpoint: endpoint(), + command: "trap 'echo caught > \(markerPath)' TERM; kill -TERM $$; echo done", + operation: TerminalImageTransferOperation() + ) + guard case .success = result else { + Issue.record("expected the command to run to completion, got \(String(describing: result))") + return + } + #expect( + FileManager.default.fileExists(atPath: markerPath), + "the command never saw SIGTERM, so it was handed this thread's blocked mask" + ) + } } From edda3a985b5da3af8a5a3876519d4a47f60345c3 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Fri, 11 Sep 2026 18:26:55 -0700 Subject: [PATCH 2/4] terminal: clear the inherited signal mask when spawning a command A signal mask survives exec, and cmux spawns upload commands and notification hooks from libdispatch workers, which run with most signals blocked. Without POSIX_SPAWN_SETSIGMASK the command inherits that mask, and so does everything it runs. A command that watches its own children through SIGCHLD then never learns they exited and waits out its internal timeouts instead. Measured with an uploader that reaps that way: each phase took exactly its own budget, 10003ms on a ten second probe and 45004ms on a forty-five second copy, for work that takes about a second. With the mask cleared the same phases took 103ms and 784ms. It only shows up under cmux because zsh clears the mask before it execs, while /bin/sh and bash pass it straight through, and /bin/sh -c is how both of these run. So the same command by hand is fast and nobody can reproduce the report. Only the mask is reset. Dispositions are left alone, so an inherited SIG_IGN on SIGPIPE still lets a child see EPIPE rather than dying mid-cleanup. --- Sources/TerminalCustomUploadRunner.swift | 18 +++++++++++++++++- Sources/TerminalNotificationPolicy.swift | 12 +++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Sources/TerminalCustomUploadRunner.swift b/Sources/TerminalCustomUploadRunner.swift index dcc1d11b82ca..03f2523f038e 100644 --- a/Sources/TerminalCustomUploadRunner.swift +++ b/Sources/TerminalCustomUploadRunner.swift @@ -257,10 +257,26 @@ struct TerminalCustomUploadRunner { throw uploadError("Failed to prepare upload command.") } defer { posix_spawnattr_destroy(&attributes) } + // A signal mask survives exec, and this spawns from a libdispatch worker, whose + // threads run with most signals blocked. Without SETSIGMASK the command inherits + // that mask and so does everything it runs, including SIGCHLD. A command that + // watches its own children through SIGCHLD then never learns they exited and + // waits out its internal timeouts instead. Measured with an uploader that reaps + // that way: each phase took exactly its own budget, 10003ms on a ten second probe + // and 45004ms on a forty-five second copy, for work that takes about a second; + // 103ms and 784ms with the mask cleared. Only the mask is reset, not signal + // dispositions -- an inherited SIG_IGN on SIGPIPE is what lets a child see EPIPE + // instead of dying mid-cleanup. + var emptyMask = sigset_t() + sigemptyset(&emptyMask) // New process group led by the child (pgid == child pid) so the whole tree // can be signalled with kill(-pid, …). If this fails we must not spawn, // else timeout/cancel couldn't tear the group down. - guard posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP)) == 0, + guard posix_spawnattr_setsigmask(&attributes, &emptyMask) == 0, + posix_spawnattr_setflags( + &attributes, + Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGMASK) + ) == 0, posix_spawnattr_setpgroup(&attributes, 0) == 0 else { throw uploadError("Failed to prepare upload command.") } diff --git a/Sources/TerminalNotificationPolicy.swift b/Sources/TerminalNotificationPolicy.swift index 79b0235d5c4b..c77e2df0848f 100644 --- a/Sources/TerminalNotificationPolicy.swift +++ b/Sources/TerminalNotificationPolicy.swift @@ -609,7 +609,17 @@ private final class NotificationHookProcessRun: @unchecked Sendable { var attributes: posix_spawnattr_t? try throwIfPOSIXError(posix_spawnattr_init(&attributes), operation: "initialize spawn attributes") defer { posix_spawnattr_destroy(&attributes) } - let flags = Int16(POSIX_SPAWN_SETPGROUP) + // Hooks are spawned from a dispatch queue, and a dispatch worker runs with most + // signals blocked. A mask survives exec, so without this the hook and everything + // it runs inherit that mask; see the longer note in TerminalCustomUploadRunner. + // Dispositions are left alone: this clears the mask, not an inherited SIG_IGN. + var emptyMask = sigset_t() + sigemptyset(&emptyMask) + try throwIfPOSIXError( + posix_spawnattr_setsigmask(&attributes, &emptyMask), + operation: "clear inherited signal mask" + ) + let flags = Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGMASK) try throwIfPOSIXError(posix_spawnattr_setflags(&attributes, flags), operation: "set spawn flags") try throwIfPOSIXError(posix_spawnattr_setpgroup(&attributes, 0), operation: "set process group") let arguments = ["/bin/sh", "-c", hook.command] From 123da7a0ef16dd70b8ee9409833b5cea94593344 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Fri, 11 Sep 2026 18:27:10 -0700 Subject: [PATCH 3/4] terminal: add regression tests for tearing down a spawned process group Red on this commit, and only reachable now that the previous one lets SIGTERM through. Each test starts a command whose shell exits on SIGTERM while a descendant it left behind ignores it, then checks the descendant is gone once the runner returns. It is not: both runners read the shell's exit as the command being over, skip the SIGKILL they owed the group, and leave the descendant running with the pipes open. While SIGTERM was blocked the shell always outlived the grace period, so the SIGKILL always went out and caught everything. That is why these tests pass on the commit before last and fail here. --- cmuxTests/NotificationAndMenuBarTests.swift | 82 +++++++++++++++++++++ cmuxTests/TerminalUploadCommandTests.swift | 76 +++++++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/cmuxTests/NotificationAndMenuBarTests.swift b/cmuxTests/NotificationAndMenuBarTests.swift index fa5ba21c6711..2a2090eacef1 100644 --- a/cmuxTests/NotificationAndMenuBarTests.swift +++ b/cmuxTests/NotificationAndMenuBarTests.swift @@ -46,6 +46,88 @@ final class TerminalNotificationPolicyEngineTests: XCTestCase { ) } + func testHookCancellationKillsADescendantThatOutlivesTheLeader() async throws { + // The hook's own shell dies on SIGTERM while the descendant it left behind + // ignores it. Reaping the shell at that point would end the run and cancel the + // escalation, leaving the descendant running. + // + // Cancelling the evaluation is what starts the teardown here, rather than the + // hook's timeout. The descendant has to exist before there is anything to + // prove, and a one second budget can expire on a loaded machine before the + // shell is ever scheduled; the test would then fail having tested nothing. + // Both paths run the same termination code, so the regression is still + // covered. The timeout is only a backstop. + let pidPath = NSTemporaryDirectory() + "cmux-hook-teardown-\(UUID().uuidString).pid" + defer { try? FileManager.default.removeItem(atPath: pidPath) } + + let request = TerminalNotificationPolicyRequest( + tabId: UUID(), + surfaceId: UUID(), + title: "Title", + subtitle: "Subtitle", + body: "Body", + cwd: FileManager.default.temporaryDirectory.path, + isAppFocused: false, + isFocusedPanel: false + ) + let hook = CmuxResolvedNotificationHook( + id: "teardown", + command: "/bin/sh -c 'trap \"\" TERM; echo $$ > \(pidPath); exec /bin/sleep 30' & wait", + timeoutSeconds: 60, + sourcePath: nil, + cwd: FileManager.default.temporaryDirectory.path + ) + + let evaluation = Task { + await TerminalNotificationPolicyEngine.evaluate(request: request, hooks: [hook]) + } + let recorded = await Self.waitForRecordedPID(atPath: pidPath, within: 20) + evaluation.cancel() + + let result = await evaluation.value + guard case .failure = result else { + XCTFail("a cancelled hook must fail closed") + return + } + + guard let descendant = recorded else { + XCTFail("the descendant never recorded its pid, so this proved nothing") + return + } + let died = Self.waitForExit(descendant, within: 5) + if !died { kill(descendant, SIGKILL) } + XCTAssertTrue(died, "a descendant that ignores SIGTERM must not survive teardown") + } + + private static func recordedPID(atPath path: String) -> pid_t? { + guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil } + return pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + /// The pid the spawned descendant wrote to `path`, waiting up to `seconds` for it + /// to appear. A partially written file reads back as nil, so keep polling. Sleeps + /// rather than spinning: the evaluation it is waiting on runs on the same pool. + private static func waitForRecordedPID(atPath path: String, within seconds: TimeInterval) async -> pid_t? { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + if let pid = recordedPID(atPath: path) { return pid } + try? await Task.sleep(nanoseconds: 20_000_000) + } + return recordedPID(atPath: path) + } + + /// Whether `pid` is gone within `seconds`. Polled rather than waited on: it is not + /// our child, so there is no exit to wait for — the reparented process is reaped by + /// launchd and `kill(pid, 0)` starts failing. + private static func waitForExit(_ pid: pid_t, within seconds: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + if kill(pid, 0) != 0 { return true } + usleep(20_000) + } + return kill(pid, 0) != 0 + } + func testHookCanDisableDesktopAndTransformBody() async throws { let request = TerminalNotificationPolicyRequest( tabId: UUID(), diff --git a/cmuxTests/TerminalUploadCommandTests.swift b/cmuxTests/TerminalUploadCommandTests.swift index d8eeb51337a2..266ab112ab30 100644 --- a/cmuxTests/TerminalUploadCommandTests.swift +++ b/cmuxTests/TerminalUploadCommandTests.swift @@ -363,4 +363,80 @@ import Testing "the command never saw SIGTERM, so it was handed this thread's blocked mask" ) } + + @Test func realProcessKillsADescendantThatOutlivesTheLeader() { + // The leader dies on SIGTERM and the descendant it left behind ignores it, so + // reading the leader's exit as "the command is gone" leaves that descendant + // running with our pipes still open. Teardown has to escalate to the group. + // + // Cancellation drives the teardown rather than the timeout, because the + // descendant has to exist before there is anything to prove. A one second + // budget can expire on a loaded machine before the shell is ever scheduled, + // and the test would then fail having tested nothing. Waiting for the pid file + // makes the descendant's arrival the trigger; the timeout is only a backstop. + let pidPath = NSTemporaryDirectory() + "cmux-upload-teardown-\(UUID().uuidString).pid" + defer { try? FileManager.default.removeItem(atPath: pidPath) } + + let operation = TerminalImageTransferOperation() + DispatchQueue.global(qos: .userInitiated).async { + _ = Self.waitForRecordedPID(atPath: pidPath, within: 20) + operation.cancel() + } + + let result = TerminalCustomUploadRunner().runSync( + fileURLs: [URL(fileURLWithPath: "/tmp/a.png")], + endpoint: endpoint(), + command: "/bin/sh -c 'trap \"\" TERM; echo $$ > \(pidPath); exec /bin/sleep 30' & wait", + operation: operation, + timeout: 60 + ) + if case .success = result { Issue.record("a cancelled command must fail closed") } + + guard let descendant = Self.recordedPID(atPath: pidPath) else { + Issue.record("the descendant never recorded its pid, so this proved nothing") + return + } + let died = Self.waitForExit(descendant, within: 5) + if !died { kill(descendant, SIGKILL) } + #expect(died, "a descendant that ignores SIGTERM must not survive teardown") + } + + private static func recordedPID(atPath path: String) -> pid_t? { + guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil } + return pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + /// Whether `path` shows up within `seconds`. The command touches it to say it has + /// reached the state the test needs before teardown starts. + private static func waitForFile(atPath path: String, within seconds: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + if FileManager.default.fileExists(atPath: path) { return true } + usleep(20_000) + } + return FileManager.default.fileExists(atPath: path) + } + + /// The pid the spawned descendant wrote to `path`, waiting up to `seconds` for it + /// to appear. A partially written file reads back as nil, so keep polling. + private static func waitForRecordedPID(atPath path: String, within seconds: TimeInterval) -> pid_t? { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + if let pid = recordedPID(atPath: path) { return pid } + usleep(20_000) + } + return recordedPID(atPath: path) + } + + /// Whether `pid` is gone within `seconds`. Polled rather than waited on: it is not + /// our child, so there is no exit to wait for — the reparented process is reaped by + /// launchd and `kill(pid, 0)` starts failing. + private static func waitForExit(_ pid: pid_t, within seconds: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + if kill(pid, 0) != 0 { return true } + usleep(20_000) + } + return kill(pid, 0) != 0 + } } From 4473605bb5bb4a217a26959926737be6af120154 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Fri, 11 Sep 2026 18:27:24 -0700 Subject: [PATCH 4/4] terminal: finish tearing down a process group after its leader exits Teardown sent SIGTERM to the group and escalated to SIGKILL only if the leader was still alive afterwards. A leader that dies on SIGTERM is not the group dying: a descendant that ignores SIGTERM survives, keeps the output pipes open, and stalls the drain to its own deadline. So the group is now signalled on its own account. SIGKILL goes out once the leader is gone or the grace period runs out, whichever comes first, and the leader is not reaped until that is done. The ordering matters: reaping the leader frees its pgid for reuse, and the kill would then be addressed to whatever group inherits the id. The upload runner splits its old reap into an observe step, waitid with WNOWAIT, and a collect step that runs after the escalation. The hook runner holds its exit handler back while the escalation timer still owes the group a signal, and that timer reaps and finishes the run itself. --- Sources/TerminalCustomUploadRunner.swift | 104 +++++++++++++++-------- Sources/TerminalNotificationPolicy.swift | 16 +++- 2 files changed, 83 insertions(+), 37 deletions(-) diff --git a/Sources/TerminalCustomUploadRunner.swift b/Sources/TerminalCustomUploadRunner.swift index 03f2523f038e..7d1c57e2fbd9 100644 --- a/Sources/TerminalCustomUploadRunner.swift +++ b/Sources/TerminalCustomUploadRunner.swift @@ -317,46 +317,64 @@ struct TerminalCustomUploadRunner { stderrBuffer.set(drainPipe(stderrReadFD, cap: maxStderrBytes)); stderrDrained.signal() } - // `wakeup` is signalled by the reaper when the child exits AND by - // cancellation, so the waits below block on a real event — never a timer or - // a poll. On cancel/timeout the group gets SIGTERM, then SIGKILL after a - // bounded grace if it hasn't died. Signalling only happens while the leader - // is still alive (group non-empty), so the pgid can't have been reused. + // `wakeup` is signalled when the leader exits AND by cancellation, so the waits + // below block on a real event — never a timer or a poll. On cancel/timeout the + // group gets SIGTERM, then SIGKILL. Signalling only happens while the leader is + // unreaped, and an unreaped leader is still a member of its group, so the pgid + // cannot have been reused. let wakeup = DispatchSemaphore(value: 0) + // The leader is observed without being reaped, then reaped once teardown is + // over. `mayReap` is what says teardown is over, so the two stages stay on this + // one thread and the blocking waits never move onto the caller's. + let mayReap = DispatchSemaphore(value: 0) + let reaped = DispatchSemaphore(value: 0) DispatchQueue.global(qos: .userInitiated).async { - spawned.reap() + spawned.awaitLeaderExit() wakeup.signal() + mayReap.wait() + spawned.collectLeader() + reaped.signal() } operation.installCancellationHandler { spawned.signalGroup(SIGTERM) wakeup.signal() } defer { operation.clearCancellationHandler() } + // Whatever happens below, the leader gets reaped: an early return would otherwise + // park the reaper thread forever and leave a zombie. Signalling twice is fine, + // the reaper waits once. + defer { mayReap.signal() } - // Blocks on `wakeup` until the child is reaped or `grace` elapses. - func awaitExit(within grace: TimeInterval) -> Bool { + // Blocks on `wakeup` until the leader exits or `grace` elapses. + func awaitLeaderExit(within grace: TimeInterval) -> Bool { let deadline = DispatchTime.now() + grace - while !spawned.isReaped { + while !spawned.hasLeaderExited { if wakeup.wait(timeout: deadline) == .timedOut { break } } - return spawned.isReaped + return spawned.hasLeaderExited } // First wait: exit, cancel, or the timeout budget — whichever comes first. let deadline = DispatchTime.now() + timeout - while !spawned.isReaped && !operation.isCancelled { + while !spawned.hasLeaderExited && !operation.isCancelled { if wakeup.wait(timeout: deadline) == .timedOut { break } } - var timedOut = false - if !spawned.isReaped { - timedOut = !operation.isCancelled + let timedOut = !spawned.hasLeaderExited && !operation.isCancelled + if timedOut || operation.isCancelled { spawned.signalGroup(SIGTERM) - if !awaitExit(within: 1) { - spawned.signalGroup(SIGKILL) - _ = awaitExit(within: 5) - } - } + // The leader exiting is not the group exiting. A descendant that ignores + // SIGTERM outlives it, keeps our pipes open, and would survive the whole + // teardown if the leader's exit were read as success — so SIGKILL goes to + // the group either way, as soon as the leader is gone or the grace runs out. + // Reaping waits until after that: a reaped leader frees its pgid for reuse, + // and the kill would then be addressed to whatever group inherits the id. + _ = awaitLeaderExit(within: 1) + spawned.signalGroup(SIGKILL) + _ = awaitLeaderExit(within: 5) + } + mayReap.signal() + _ = reaped.wait(timeout: .now() + 5) // The leader exited, but a descendant — possibly `setsid`'d out of the // group, so a group kill can't reach it — could still hold a write end @@ -455,17 +473,40 @@ struct TerminalCustomUploadRunner { /// A spawned child process group, led by the `/bin/sh` child. A lock guards the /// wait status across the reaper thread and the caller; an actor can't own the - /// blocking `waitpid` this wraps. + /// blocking waits this wraps. The leader's exit and its reaping are separate steps + /// because teardown has to keep signalling the group after the leader is gone. private final class SpawnedProcess: @unchecked Sendable { private let lock = NSLock() private let groupID: pid_t private var rawStatus: Int32 = 0 - private var reaped = false + private var leaderExited = false + private var collected = false init(pid: pid_t) { groupID = pid } - /// Blocks until the group leader (`/bin/sh`) exits and records its status. - func reap() { + /// Blocks until the group leader (`/bin/sh`) exits, leaving it unreaped. The + /// zombie is still a member of the process group, which keeps the pgid from + /// being handed to anything else while teardown is still signalling it. + func awaitLeaderExit() { + var info = siginfo_t() + while waitid(P_PID, id_t(groupID), &info, WEXITED | WNOWAIT) == -1 { + if errno == EINTR { continue } + break + } + lock.lock() + leaderExited = true + lock.unlock() + } + + var hasLeaderExited: Bool { + lock.lock() + defer { lock.unlock() } + return leaderExited + } + + /// Reaps the leader and records its status. Blocks, so callers hand this to the + /// same thread that did `awaitLeaderExit`. + func collectLeader() { var status: Int32 = 0 while true { let result = waitpid(groupID, &status, 0) @@ -476,25 +517,20 @@ struct TerminalCustomUploadRunner { } lock.lock() rawStatus = status - reaped = true + leaderExited = true + collected = true lock.unlock() } - var isReaped: Bool { - lock.lock() - defer { lock.unlock() } - return reaped - } - /// Signals the whole process group, unless the leader has been reaped — once /// reaped, the pgid may be empty and reusable, so signalling it could hit an - /// unrelated group. The reap flag is checked and the signal sent under the - /// same lock that `reap()` sets it with, so a signal never races past reap. - /// We only ever signal the group, never a bare pid. + /// unrelated group. The collected flag is checked and the signal sent under the + /// same lock that `collectLeader()` sets it with, so a signal never races past + /// the reap. We only ever signal the group, never a bare pid. func signalGroup(_ signal: Int32) { lock.lock() defer { lock.unlock() } - guard !reaped else { return } + guard !collected else { return } _ = kill(-groupID, signal) } diff --git a/Sources/TerminalNotificationPolicy.swift b/Sources/TerminalNotificationPolicy.swift index c77e2df0848f..3a5e8a606e14 100644 --- a/Sources/TerminalNotificationPolicy.swift +++ b/Sources/TerminalNotificationPolicy.swift @@ -831,11 +831,16 @@ private final class NotificationHookProcessRun: @unchecked Sendable { let source = DispatchSource.makeTimerSource(queue: queue) source.schedule(deadline: .now() + .milliseconds(750)) source.setEventHandler { [self] in - if self.processId > 0 { - self.signalProcessGroup(SIGKILL) - } + self.signalProcessGroup(SIGKILL) self.killSource?.cancel() self.killSource = nil + // A leader that exited during the grace period was left unreaped so its pgid + // would still be this group's when the SIGKILL above went out. Collect it now + // and finish. If it is still running, SIGKILL has just ended it and the exit + // source finishes the run instead. + if let status = self.reapProcessIfExited() { + self.finish(rawStatus: status) + } } killSource = source source.resume() @@ -849,6 +854,11 @@ private final class NotificationHookProcessRun: @unchecked Sendable { } private func processExited() { + // The leader exiting is not the group exiting: a descendant that ignores SIGTERM + // outlives it. Reaping here would end the run and cancel the escalation timer, + // and would also free the pgid, so the SIGKILL that timer owes the group could + // land on a reused one. Leave the zombie in place and let the timer finish. + if didRequestTermination, killSource != nil { return } guard let status = waitForProcessExit() else { return } finish(rawStatus: status) }