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:
Lawrence Chen 2026-02-21 05:09:27 -08:00 committed by GitHub
commit 356a20e97a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 399 additions and 54 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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,

View file

@ -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!)
]
)
}
}

View 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())