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
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ final class CreationInputCoordinator {
}
let (inputs, skipped) = HeldKeyInput.inputs(for: events, optionAsAlt: GhosttyRuntime.shared.optionAsAltValue), surface = tab.surface
if skipped > 0 { Self.logger.error("\(skipped, privacy: .public) held key(s) have no daemon key name") }
services.daemon(for: pane).send("send-key") { connection in
services.daemon(for: pane).sendInOrder("send-key", to: surface) { connection in // after this terminal's earlier held sends
for input in inputs {
switch input {
case .text(let text): try await connection.send(surface, text: text)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
public import CmuxNextActions
public import CmuxNextDaemon

extension DaemonService {
/// Sends `body` once every earlier ordered send to terminal `surface` has ended, so input sent this way reaches the
/// terminal in the order it was sent, also when one send makes several requests (text, then a key) and a later one
/// would otherwise run between them (P0 1193: held keys for a terminal whose view is gone). The ticket opens now,
/// as ``send(_:_:)``'s does; a failure is logged as there, and the next send still runs.
public func sendInOrder(_ label: String, to surface: SurfaceID, _ body: @escaping DaemonCommandBody) {
let ticket = openTicket(label), previous = orderedSends[surface]?.work
orderedSendSerial += 1
let serial = orderedSendSerial
// task-owner: one ordered send; the next send to this surface awaits it, and the last one clears the entry. It
// keeps the service alive as `send(_:_:)` does, so its ticket always closes and a gone connection is reported
// ("not connected"), never a silent success with the keys lost.
let work: ActionWork = Task {
_ = await previous?.value
let failed = await failure(label, ticket: ticket, body)
if orderedSends[surface]?.serial == serial { orderedSends[surface] = nil }
return failed
}
orderedSends[surface] = (serial, work)
workTracker?(work)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ public final class DaemonService {
#endif
/// Set while an administrator turned this machine's feature off: no endpoint, no re-attach.
public let policyBlock = PolicyBlock()
/// The last ordered send to each terminal surface (``sendInOrder(_:to:_:)``): the next one waits for it.
@ObservationIgnored var orderedSends: [SurfaceID: (serial: UInt64, work: ActionWork)] = [:]
@ObservationIgnored var orderedSendSerial: UInt64 = 0
/// Retry Now and Restart cmux-tui while the local daemon cannot be reached (cx-jniyz.1).
public let recovery = DaemonRecovery()
public private(set) var connection: DaemonConnection?
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import CmuxNextActions
@testable import CmuxNextApp
@testable import CmuxNextDaemon
import Testing

/// P0 1193 (review of #29919 at a51d0a85518, lead 01:4xZ): an ordered send whose service is released before its turn
/// (a Cloud, SSH or Server session's DaemonService goes away) must not report success with its keys lost. Its ticket
/// closes, and its result is a failure ("not connected"). Before, the queued task held the service weakly and returned
/// success when it was gone, and the ticket stayed open until the action scope's timeout.
@MainActor @Suite struct OrderedSendReleaseTests {
@Test func anOrderedSendWhoseServiceIsGoneFailsAndClosesItsTicket() async {
let scope = DaemonCommandScope()
var tracked: [ActionWork] = []
var service: DaemonService? = DaemonService(machineID: "cloud:released")
service?.workTracker = { tracked.append($0) }
DaemonCommandScope.$current.withValue(scope) {
service?.sendInOrder("send-key", to: SurfaceID(rawValue: 7)) { _ in }
service?.sendInOrder("send-key", to: SurfaceID(rawValue: 7)) { _ in }
}
// The service goes away before either send's turn (no await between the queueing and the release).
service = nil
var results: [ActionWorkFailure?] = []
for work in tracked { results.append(await work.value) }
#expect(tracked.count == 2, "the sends were not tracked: \(tracked.count)")
#expect(results.allSatisfy { $0 != nil }, "a send whose service was gone reported success (its keys lost silently): \(results)")
#expect(scope.isIdle, "a ticket of a send whose service was gone stayed open")
#expect(scope.failures.count == 2, "the scope did not record the lost sends as failures: \(scope.failures)")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -552,4 +552,47 @@ struct SecondCreationKeysTests {
#expect(inOrder, "the keys held for the evicted terminal did not arrive as typed (61 62 0d 03): \(first.suffix(300))")
}
}

/// P0 1193 (re-review of #29693 at bc84781571b, lead 00:2xZ): two typing runs held for one terminal whose view is gone
/// (keys for another terminal typed between them) reach that terminal in the order they were typed. Each run is its
/// own daemon send; before, a run of more than one request ("ab", then Return) could let the next run ("cd") in
/// between its requests, so the shell got "ab", "cd", Return.
@Test func twoHeldRunsForAnEvictedTerminalKeepTheirOrder() async throws {
try await Self.withTwoTerminals { p in
let coordinator = Self.replayInTestWindow(p)
let nonce = String(UInt32.random(in: 100_000...999_999))
try await Self.focus(p.first.pane, tab: p.first.tab, in: p)
try await Self.startByteReader(nonce: nonce, count: 5, on: p.first.surface, in: p)
// Two creations with no early view: every key below waits, on any build.
let first = try #require(coordinator.begin(in: p.window, generation: p.content.beginFocusIntent(), tab: p.first.tab))
let second = try #require(coordinator.begin(in: p.window, generation: p.content.beginFocusIntent(), tab: p.second.tab))
var held = try Self.type("ab", in: p)
held.append(try Self.press("\r", [], keyCode: 36, in: p)) // run 1 for the first terminal: "ab", then Return
try await Self.focus(p.second.pane, tab: p.second.tab, in: p)
held += try Self.type("x", in: p) // for the second terminal, between the two runs
try await Self.focus(p.first.pane, tab: p.first.tab, in: p)
held += try Self.type("cd", in: p) // run 2 for the first terminal
try #require(held.allSatisfy { $0 }, "the test needs every key held: \(held)")

// Another workspace shows and the first one hibernates: both terminals lose their views, so the daemon types.
let store = p.services.machines.local.store
let firstWorkspace = try #require(store.workspace(containing: p.first.pane.pane.handle)?.id, "the first terminal's pane is in no workspace")
#expect(p.services.registry.perform("newTab", invocation: ActionInvocation(origin: .user)))
try await PinnedPageTests.until({ p.services.windows.active?.workspaceContent !== p.content },
report: { "waited for another workspace in the window; it still shows the first one" })
p.services.hibernateWorkspace(firstWorkspace)
try await PinnedPageTests.until({ p.services.cache.existingTerminal(p.first.tab) == nil }, within: .seconds(5), report: {
"waited for the first terminal's view to be evicted; it is still in the content cache"
})
coordinator.resolve(first, landed: true, in: p.window)
coordinator.resolve(second, landed: true, in: p.window)
try await PinnedPageTests.until({ !coordinator.isHolding(in: p.window) }, within: .seconds(5), report: {
"waited for the hold to end after the replies; saw end \(String(describing: coordinator.lastHoldEnd(in: p.window)))"
})
// The reader prints the hex of the 5 bytes it got: a b Return c d, in that order.
let inOrder = try await Self.readerGot("K42x\(nonce)61620d6364K", on: p.first.surface, in: p)
let screen = await Self.screen(p.first.surface, in: p)
#expect(inOrder, "the two runs held for the evicted terminal did not arrive as typed (61 62 0d 63 64): \(screen.suffix(300))")
}
}
}
Loading