Merge pull request #270 from manaflow-ai/task-cli-commands-off-main-thread
Reduce CLI workspace-creation UI lag and add socket timing logs
This commit is contained in:
commit
356a20e97a
5 changed files with 399 additions and 54 deletions
|
|
@ -1132,6 +1132,10 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
private var lastPixelHeight: UInt32 = 0
|
||||
private var lastXScale: CGFloat = 0
|
||||
private var lastYScale: CGFloat = 0
|
||||
private var pendingTextQueue: [Data] = []
|
||||
private var pendingTextBytes: Int = 0
|
||||
private let maxPendingTextBytes = 1_048_576
|
||||
private var backgroundSurfaceStartQueued = false
|
||||
@Published var searchState: SearchState? = nil {
|
||||
didSet {
|
||||
if let searchState {
|
||||
|
|
@ -1496,6 +1500,8 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
lastXScale = scaleFactors.x
|
||||
lastYScale = scaleFactors.y
|
||||
}
|
||||
|
||||
flushPendingTextIfNeeded()
|
||||
}
|
||||
|
||||
func updateSize(width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat) {
|
||||
|
|
@ -1603,14 +1609,83 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
func sendText(_ text: String) {
|
||||
guard let surface = surface else { return }
|
||||
guard let data = text.data(using: .utf8), !data.isEmpty else { return }
|
||||
guard let surface = surface else {
|
||||
enqueuePendingText(data)
|
||||
return
|
||||
}
|
||||
writeTextData(data, to: surface)
|
||||
}
|
||||
|
||||
func requestBackgroundSurfaceStartIfNeeded() {
|
||||
if !Thread.isMainThread {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.requestBackgroundSurfaceStartIfNeeded()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard surface == nil, attachedView != nil else { return }
|
||||
guard !backgroundSurfaceStartQueued else { return }
|
||||
backgroundSurfaceStartQueued = true
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.backgroundSurfaceStartQueued = false
|
||||
guard self.surface == nil, let view = self.attachedView else { return }
|
||||
#if DEBUG
|
||||
let startedAt = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
self.createSurface(for: view)
|
||||
#if DEBUG
|
||||
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0
|
||||
dlog(
|
||||
"surface.background_start surface=\(self.id.uuidString.prefix(8)) inWindow=\(view.window != nil ? 1 : 0) ready=\(self.surface != nil ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func writeTextData(_ data: Data, to surface: ghostty_surface_t) {
|
||||
data.withUnsafeBytes { rawBuffer in
|
||||
guard let baseAddress = rawBuffer.baseAddress?.assumingMemoryBound(to: CChar.self) else { return }
|
||||
ghostty_surface_text(surface, baseAddress, UInt(rawBuffer.count))
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueuePendingText(_ data: Data) {
|
||||
let incomingBytes = data.count
|
||||
while !pendingTextQueue.isEmpty && pendingTextBytes + incomingBytes > maxPendingTextBytes {
|
||||
let dropped = pendingTextQueue.removeFirst()
|
||||
pendingTextBytes -= dropped.count
|
||||
}
|
||||
|
||||
pendingTextQueue.append(data)
|
||||
pendingTextBytes += incomingBytes
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.send_text.queue surface=\(id.uuidString.prefix(8)) chunks=\(pendingTextQueue.count) bytes=\(pendingTextBytes)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func flushPendingTextIfNeeded() {
|
||||
guard let surface = surface, !pendingTextQueue.isEmpty else { return }
|
||||
let queued = pendingTextQueue
|
||||
let queuedBytes = pendingTextBytes
|
||||
pendingTextQueue.removeAll(keepingCapacity: false)
|
||||
pendingTextBytes = 0
|
||||
|
||||
for chunk in queued {
|
||||
writeTextData(chunk, to: surface)
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.send_text.flush surface=\(id.uuidString.prefix(8)) chunks=\(queued.count) bytes=\(queuedBytes)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func performBindingAction(_ action: String) -> Bool {
|
||||
guard let surface = surface else { return false }
|
||||
return action.withCString { cString in
|
||||
|
|
|
|||
|
|
@ -515,7 +515,11 @@ class TerminalController {
|
|||
let cmd = parts[0].lowercased()
|
||||
let args = parts.count > 1 ? parts[1] : ""
|
||||
|
||||
return withSocketCommandPolicy(commandKey: cmd, isV2: false) {
|
||||
#if DEBUG
|
||||
let startedAt = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
|
||||
let response = withSocketCommandPolicy(commandKey: cmd, isV2: false) {
|
||||
switch cmd {
|
||||
case "ping":
|
||||
return "PONG"
|
||||
|
|
@ -807,6 +811,18 @@ class TerminalController {
|
|||
return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands."
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if cmd == "new_workspace" || cmd == "send" || cmd == "send_surface" {
|
||||
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0
|
||||
let status = response.hasPrefix("OK") ? "ok" : "err"
|
||||
dlog(
|
||||
"socket.v1 cmd=\(cmd) status=\(status) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// MARK: - V2 JSON Socket Protocol
|
||||
|
|
@ -841,7 +857,11 @@ class TerminalController {
|
|||
v2MainSync { self.v2RefreshKnownRefs() }
|
||||
|
||||
|
||||
return withSocketCommandPolicy(commandKey: method, isV2: true) {
|
||||
#if DEBUG
|
||||
let startedAt = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
|
||||
let response = withSocketCommandPolicy(commandKey: method, isV2: true) {
|
||||
switch method {
|
||||
case "system.ping":
|
||||
return v2Ok(id: id, result: ["pong": true])
|
||||
|
|
@ -1181,6 +1201,18 @@ class TerminalController {
|
|||
return v2Error(id: id, code: "method_not_found", message: "Unknown method")
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if method == "workspace.create" || method == "surface.send_text" {
|
||||
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0
|
||||
let status = response.contains("\"ok\":true") ? "ok" : "err"
|
||||
dlog(
|
||||
"socket.v2 method=\(method) status=\(status) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private func v2Capabilities() -> [String: Any] {
|
||||
|
|
@ -1781,10 +1813,20 @@ class TerminalController {
|
|||
}
|
||||
|
||||
var newId: UUID?
|
||||
let shouldFocus = v2FocusAllowed()
|
||||
#if DEBUG
|
||||
let startedAt = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
v2MainSync {
|
||||
let ws = tabManager.addWorkspace(select: v2FocusAllowed())
|
||||
let ws = tabManager.addWorkspace(select: shouldFocus)
|
||||
newId = ws.id
|
||||
}
|
||||
#if DEBUG
|
||||
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0
|
||||
dlog(
|
||||
"socket.workspace.create focus=\(shouldFocus ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
|
||||
guard let newId else {
|
||||
return .err(code: "internal_error", message: "Failed to create workspace", data: nil)
|
||||
|
|
@ -3109,24 +3151,38 @@ class TerminalController {
|
|||
result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString])
|
||||
return
|
||||
}
|
||||
guard let surface = waitForTerminalSurface(terminalPanel, waitUpTo: 2.0) else {
|
||||
result = .err(code: "internal_error", message: "Surface not ready", data: ["surface_id": surfaceId.uuidString])
|
||||
return
|
||||
#if DEBUG
|
||||
let sendStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
let queued: Bool
|
||||
if let surface = terminalPanel.surface.surface {
|
||||
sendSocketText(text, surface: surface)
|
||||
// Ensure we present a new frame after injecting input so snapshot-based tests (and
|
||||
// socket-driven agents) can observe the updated terminal without requiring a focus
|
||||
// change to trigger a draw.
|
||||
terminalPanel.surface.forceRefresh()
|
||||
queued = false
|
||||
} else {
|
||||
// Avoid blocking the main actor waiting for view/surface attachment.
|
||||
terminalPanel.sendText(text)
|
||||
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
|
||||
queued = true
|
||||
}
|
||||
|
||||
for char in text {
|
||||
if char.unicodeScalars.count == 1,
|
||||
let scalar = char.unicodeScalars.first,
|
||||
handleControlScalar(scalar, surface: surface) {
|
||||
continue
|
||||
}
|
||||
sendTextEvent(surface: surface, text: String(char))
|
||||
}
|
||||
// Ensure we present a new frame after injecting input so snapshot-based tests (and
|
||||
// socket-driven agents) can observe the updated terminal without requiring a focus
|
||||
// change to trigger a draw.
|
||||
terminalPanel.surface.forceRefresh()
|
||||
result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))])
|
||||
#if DEBUG
|
||||
let sendMs = (ProcessInfo.processInfo.systemUptime - sendStart) * 1000.0
|
||||
dlog(
|
||||
"socket.surface.send_text workspace=\(ws.id.uuidString.prefix(8)) surface=\(surfaceId.uuidString.prefix(8)) queued=\(queued ? 1 : 0) chars=\(text.count) ms=\(String(format: "%.2f", sendMs))"
|
||||
)
|
||||
#endif
|
||||
result = .ok([
|
||||
"workspace_id": ws.id.uuidString,
|
||||
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
||||
"surface_id": surfaceId.uuidString,
|
||||
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
|
||||
"queued": queued,
|
||||
"window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString),
|
||||
"window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))
|
||||
])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -3154,7 +3210,7 @@ class TerminalController {
|
|||
result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString])
|
||||
return
|
||||
}
|
||||
guard let surface = waitForTerminalSurface(terminalPanel, waitUpTo: 2.0) else {
|
||||
guard let surface = terminalPanel.surface.surface else {
|
||||
result = .err(code: "internal_error", message: "Surface not ready", data: ["surface_id": surfaceId.uuidString])
|
||||
return
|
||||
}
|
||||
|
|
@ -8753,10 +8809,19 @@ class TerminalController {
|
|||
|
||||
var newTabId: UUID?
|
||||
let focus = socketCommandAllowsInAppFocusMutations()
|
||||
#if DEBUG
|
||||
let startedAt = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
DispatchQueue.main.sync {
|
||||
let workspace = tabManager.addTab(select: focus)
|
||||
newTabId = workspace.id
|
||||
}
|
||||
#if DEBUG
|
||||
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0
|
||||
dlog(
|
||||
"socket.new_workspace focus=\(focus ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
return "OK \(newTabId?.uuidString ?? "unknown")"
|
||||
}
|
||||
|
||||
|
|
@ -9741,6 +9806,69 @@ class TerminalController {
|
|||
sendKeyEvent(surface: surface, keycode: 0, text: text)
|
||||
}
|
||||
|
||||
enum SocketTextChunk: Equatable {
|
||||
case text(String)
|
||||
case control(UnicodeScalar)
|
||||
}
|
||||
|
||||
nonisolated static func socketTextChunks(_ text: String) -> [SocketTextChunk] {
|
||||
guard !text.isEmpty else { return [] }
|
||||
|
||||
var chunks: [SocketTextChunk] = []
|
||||
chunks.reserveCapacity(8)
|
||||
var bufferedText = ""
|
||||
bufferedText.reserveCapacity(text.count)
|
||||
|
||||
func flushBufferedText() {
|
||||
guard !bufferedText.isEmpty else { return }
|
||||
chunks.append(.text(bufferedText))
|
||||
bufferedText.removeAll(keepingCapacity: true)
|
||||
}
|
||||
|
||||
for scalar in text.unicodeScalars {
|
||||
if isSocketControlScalar(scalar) {
|
||||
flushBufferedText()
|
||||
chunks.append(.control(scalar))
|
||||
} else {
|
||||
bufferedText.unicodeScalars.append(scalar)
|
||||
}
|
||||
}
|
||||
flushBufferedText()
|
||||
return chunks
|
||||
}
|
||||
|
||||
private nonisolated static func isSocketControlScalar(_ scalar: UnicodeScalar) -> Bool {
|
||||
switch scalar.value {
|
||||
case 0x0A, 0x0D, 0x09, 0x1B, 0x7F:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func sendSocketText(_ text: String, surface: ghostty_surface_t) {
|
||||
let chunks = Self.socketTextChunks(text)
|
||||
#if DEBUG
|
||||
let startedAt = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
for chunk in chunks {
|
||||
switch chunk {
|
||||
case .text(let value):
|
||||
sendTextEvent(surface: surface, text: value)
|
||||
case .control(let scalar):
|
||||
_ = handleControlScalar(scalar, surface: surface)
|
||||
}
|
||||
}
|
||||
#if DEBUG
|
||||
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0
|
||||
if elapsedMs >= 8 || chunks.count > 1 {
|
||||
dlog(
|
||||
"socket.send_text.inject chars=\(text.count) chunks=\(chunks.count) ms=\(String(format: "%.2f", elapsedMs))"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func handleControlScalar(_ scalar: UnicodeScalar, surface: ghostty_surface_t) -> Bool {
|
||||
switch scalar.value {
|
||||
case 0x0A, 0x0D:
|
||||
|
|
@ -9843,15 +9971,6 @@ class TerminalController {
|
|||
return
|
||||
}
|
||||
|
||||
guard let surface = resolveTerminalSurface(
|
||||
from: terminalPanel.id.uuidString,
|
||||
tabManager: tabManager,
|
||||
waitUpTo: 2.0
|
||||
) else {
|
||||
error = "ERROR: Surface not ready"
|
||||
return
|
||||
}
|
||||
|
||||
// Unescape common escape sequences
|
||||
// Note: \n is converted to \r for terminal (Enter key sends \r)
|
||||
let unescaped = text
|
||||
|
|
@ -9859,13 +9978,11 @@ class TerminalController {
|
|||
.replacingOccurrences(of: "\\r", with: "\r")
|
||||
.replacingOccurrences(of: "\\t", with: "\t")
|
||||
|
||||
for char in unescaped {
|
||||
if char.unicodeScalars.count == 1,
|
||||
let scalar = char.unicodeScalars.first,
|
||||
handleControlScalar(scalar, surface: surface) {
|
||||
continue
|
||||
}
|
||||
sendTextEvent(surface: surface, text: String(char))
|
||||
if let surface = terminalPanel.surface.surface {
|
||||
sendSocketText(unescaped, surface: surface)
|
||||
} else {
|
||||
terminalPanel.sendText(unescaped)
|
||||
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
|
||||
}
|
||||
success = true
|
||||
}
|
||||
|
|
@ -9883,20 +10000,18 @@ class TerminalController {
|
|||
|
||||
var success = false
|
||||
DispatchQueue.main.sync {
|
||||
guard let surface = resolveSurface(from: target, tabManager: tabManager) else { return }
|
||||
guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { return }
|
||||
|
||||
let unescaped = text
|
||||
.replacingOccurrences(of: "\\n", with: "\r")
|
||||
.replacingOccurrences(of: "\\r", with: "\r")
|
||||
.replacingOccurrences(of: "\\t", with: "\t")
|
||||
|
||||
for char in unescaped {
|
||||
if char.unicodeScalars.count == 1,
|
||||
let scalar = char.unicodeScalars.first,
|
||||
handleControlScalar(scalar, surface: surface) {
|
||||
continue
|
||||
}
|
||||
sendTextEvent(surface: surface, text: String(char))
|
||||
if let surface = terminalPanel.surface.surface {
|
||||
sendSocketText(unescaped, surface: surface)
|
||||
} else {
|
||||
terminalPanel.sendText(unescaped)
|
||||
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
|
||||
}
|
||||
success = true
|
||||
}
|
||||
|
|
@ -9917,11 +10032,7 @@ class TerminalController {
|
|||
return
|
||||
}
|
||||
|
||||
guard let surface = resolveTerminalSurface(
|
||||
from: terminalPanel.id.uuidString,
|
||||
tabManager: tabManager,
|
||||
waitUpTo: 2.0
|
||||
) else {
|
||||
guard let surface = terminalPanel.surface.surface else {
|
||||
error = "ERROR: Surface not ready"
|
||||
return
|
||||
}
|
||||
|
|
@ -9943,11 +10054,11 @@ class TerminalController {
|
|||
var success = false
|
||||
var error: String?
|
||||
DispatchQueue.main.sync {
|
||||
guard resolveTerminalPanel(from: target, tabManager: tabManager) != nil else {
|
||||
guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else {
|
||||
error = "ERROR: Surface not found"
|
||||
return
|
||||
}
|
||||
guard let surface = resolveTerminalSurface(from: target, tabManager: tabManager, waitUpTo: 2.0) else {
|
||||
guard let surface = terminalPanel.surface.surface else {
|
||||
error = "ERROR: Surface not ready"
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -335,7 +335,9 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
// Configure bonsplit with keepAllAlive to preserve terminal state
|
||||
// and keep split entry instantaneous.
|
||||
let appearance = Self.bonsplitAppearance(from: GhosttyConfig.load())
|
||||
// Avoid re-reading/parsing Ghostty config on every new workspace; this hot path
|
||||
// runs for socket/CLI workspace creation and can cause visible typing lag.
|
||||
let appearance = Self.bonsplitAppearance(from: GhosttyApp.shared.defaultBackgroundColor)
|
||||
let config = BonsplitConfiguration(
|
||||
allowSplits: true,
|
||||
allowCloseTabs: true,
|
||||
|
|
|
|||
|
|
@ -3596,3 +3596,36 @@ final class TerminalControllerSidebarDedupeTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class TerminalControllerSocketTextChunkTests: XCTestCase {
|
||||
func testSocketTextChunksReturnsSingleChunkForPlainText() {
|
||||
XCTAssertEqual(
|
||||
TerminalController.socketTextChunks("echo hello"),
|
||||
[.text("echo hello")]
|
||||
)
|
||||
}
|
||||
|
||||
func testSocketTextChunksSplitsControlScalars() {
|
||||
XCTAssertEqual(
|
||||
TerminalController.socketTextChunks("abc\rdef\tghi"),
|
||||
[
|
||||
.text("abc"),
|
||||
.control("\r".unicodeScalars.first!),
|
||||
.text("def"),
|
||||
.control("\t".unicodeScalars.first!),
|
||||
.text("ghi")
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testSocketTextChunksDoesNotEmitEmptyTextChunksAroundConsecutiveControls() {
|
||||
XCTAssertEqual(
|
||||
TerminalController.socketTextChunks("\r\n\t"),
|
||||
[
|
||||
.control("\r".unicodeScalars.first!),
|
||||
.control("\n".unicodeScalars.first!),
|
||||
.control("\t".unicodeScalars.first!)
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
124
tests_v2/test_cli_new_workspace_command_queue.py
Normal file
124
tests_v2/test_cli_new_workspace_command_queue.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: `new-workspace --command` should execute without selecting the workspace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli(cli: str, args: list[str]) -> tuple[subprocess.CompletedProcess[str], float]:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
env.pop("CMUX_TAB_ID", None)
|
||||
|
||||
started = time.monotonic()
|
||||
proc = subprocess.run(
|
||||
[cli, "--socket", SOCKET_PATH] + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
elapsed = time.monotonic() - started
|
||||
return proc, elapsed
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cli = _find_cli_binary()
|
||||
marker = Path(tempfile.gettempdir()) / f"cmux_new_workspace_command_{os.getpid()}.txt"
|
||||
created_ws_id: str | None = None
|
||||
|
||||
try:
|
||||
marker.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
try:
|
||||
baseline_ws_id = c.current_workspace()
|
||||
token = f"queued-{os.getpid()}-{int(time.time() * 1000)}"
|
||||
cmd_text = f"echo {token} > {marker}"
|
||||
|
||||
proc, elapsed = _run_cli(cli, ["new-workspace", "--command", cmd_text])
|
||||
combined = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
_must(proc.returncode == 0, f"CLI failed ({proc.returncode}): {combined}")
|
||||
_must(elapsed < 1.5, f"new-workspace --command should return quickly, took {elapsed:.2f}s")
|
||||
|
||||
output = (proc.stdout or "").strip()
|
||||
_must(output.startswith("OK "), f"Expected OK response, got: {output!r}")
|
||||
_must("Surface not ready" not in combined, f"Unexpected surface readiness error: {combined}")
|
||||
created_ws_id = output[3:].strip()
|
||||
_must(bool(created_ws_id), f"Missing workspace id in output: {output!r}")
|
||||
|
||||
# Creation with --command should not steal focus.
|
||||
_must(c.current_workspace() == baseline_ws_id, "new-workspace --command should preserve selected workspace")
|
||||
|
||||
observed = ""
|
||||
deadline = time.time() + 12.0
|
||||
while time.time() < deadline:
|
||||
if marker.exists():
|
||||
try:
|
||||
observed = marker.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
observed = ""
|
||||
if observed:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
_must(marker.exists(), f"Command marker file was not created: {marker}")
|
||||
_must(observed == token, f"Queued command did not execute as expected: expected={token!r} observed={observed!r}")
|
||||
_must(c.current_workspace() == baseline_ws_id, "Command execution should not switch selected workspace")
|
||||
finally:
|
||||
if created_ws_id:
|
||||
try:
|
||||
c.close_workspace(created_ws_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
marker.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
print("PASS: new-workspace --command executes without opening the created workspace")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue