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
14 changes: 13 additions & 1 deletion Docs/LSP.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ conversation with the server, and the join between the two.
language. Owns the handshake, the open-document set and the version counters.
- **`LSP/LanguageServerRegistry.swift`** — language → binary, one server per
(root, language), started lazily the first time a matching file is opened and
prewarmed from the dashboard.
prewarmed from the dashboard. It is also what stops one again: see
**Servers do not stay forever** below.
- **`LSP/LanguageServerConfiguration.swift`**, **`LanguageServerOptions.swift`**,
**`ManagedLanguageServers.swift`** — which servers exist, their flags, and the
ones the app can fetch for you.
Expand Down Expand Up @@ -137,6 +138,17 @@ been fine; they were not why.

## Also worth knowing

- **Servers do not stay forever.** A server holding no documents for fifteen
minutes is shut down and forgotten by the registry — the node ones are a few
hundred megabytes each, sourcekit-lsp rather more, and a dashboard prewarms up
to `prewarmLimit` of them before a single file has been opened. Nothing used
to stop one short of removing its repository from the sidebar, so a day across
eight repositories left every server of all eight resident. `LanguageService`
records `idleSince` when its open-document count reaches zero and tells the
registry; there is no sweep on a clock, because a server can only become idle
at the moment its last document closes. Opening a file in that language starts
a fresh one, which is what already happens the first time — `shutdown()`
clears the remembered handshake so it can.
- **Large files are excluded on purpose.** See `OpenDocument.largeFileNote`.
`didOpen` sends the whole text, and the answer to a 3.5 MB minified bundle is a
diagnostic per line of something nobody is editing. No coordinator is even
Expand Down
18 changes: 18 additions & 0 deletions Docs/Terminal.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,24 @@ sixteen ANSI slots a terminal needs.

## Gotchas learned the hard way

- **Ghostty's callbacks arrive at frame rate, and a hop each is too many.** A
shell painting a spinner — every `claude` mid-turn — asks to render every
frame whether or not anyone is looking at that tab, and wakes the app several
times per frame besides. One `Task { @MainActor … }` per request meant the
cost of a window grew with each terminal left running in it. Both are gathered
on ghostty's own thread instead (`RenderQueue`, `Latch` at the foot of
`GhosttyRuntime.swift`), and one hop serves everything that arrived while it
was waiting its turn. The latch is cleared at the *start* of the scheduled
work, so a request landing mid-drain schedules the next pass rather than being
swallowed by this one.
- **A tab title is not published on every rename, for the same reason.** `claude`
paints its spinner into the OSC title, and each name used to be a write to an
observable property — which redraws the terminals list, the tab bar and the
viewer header. `TerminalSession.titleArrived` splits what a name *means* from
the text of it: `isWorking` (what raises the "finished" banner) is read at
once and only written when it changes, while the text lands on a 200 ms tick.
The last name of a burst is always published, so a tab never settles showing
half a spinner.
- `ghostty_surface_text` is a **paste** (`completeClipboardPaste` in core).
Under bracketed paste, a pasted `\n` never executes a command — that is why
`TerminalSession.send` turns a trailing newline into `pressEnter()`, a
Expand Down
67 changes: 64 additions & 3 deletions Sources/Workspace/LSP/LanguageServerRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ final class LanguageServerRegistry {
guard let entry = catalog.entry(forFile: url) ?? catalog.entry(for: language) else { return nil }
let key = Self.key(root: root, language: entry.language)
if let existing = services[key] { return existing }
let service = LanguageService(definition: Self.definition(for: entry), root: root)
let service = makeService(Self.definition(for: entry), root: root)
services[key] = service
return service
}
Expand Down Expand Up @@ -140,7 +140,7 @@ final class LanguageServerRegistry {

let key = Self.key(root: root, language: entry.language)
let service = services[key]
?? LanguageService(definition: Self.definition(for: entry), root: root)
?? makeService(Self.definition(for: entry), root: root)
services[key] = service
// Deliberately not awaited in turn: sourcekit-lsp can spend the
// best part of a minute on its handshake, and the servers behind it
Expand All @@ -163,7 +163,7 @@ final class LanguageServerRegistry {
}) else { return nil }
let key = Self.key(root: root, language: entry.language)
if let existing = services[key] { return existing }
let service = LanguageService(definition: Self.definition(for: entry), root: root)
let service = makeService(Self.definition(for: entry), root: root)
services[key] = service
return service
}
Expand Down Expand Up @@ -217,6 +217,67 @@ final class LanguageServerRegistry {
}
}

// MARK: - Letting idle servers go

/// How long a server may hold no documents before it is stopped.
///
/// Nothing used to stop one short of removing its repository from the
/// sidebar, and a server is not a cheap thing to leave sitting there: the
/// node ones are a few hundred megabytes each, sourcekit-lsp rather more,
/// and a repository prewarms up to ``prewarmLimit`` of them the first time
/// its dashboard is looked at. A working day across eight repositories left
/// every server of all eight resident, which is the machine slowing down
/// rather than the app — but it is the app that is blamed for it.
///
/// Long enough that reading two files in a language with a break between
/// them does not pay the handshake twice, and reading one file in a
/// repository visited by accident does not hold a server all afternoon.
private static let idleLimit: TimeInterval = 15 * 60

/// The one wait in flight. There is no sweep on a clock: a server can only
/// become idle by having its last document closed, so the wait is put on
/// when that happens and taken off when it is served.
@ObservationIgnored private var idleSweep: Task<Void, Never>?

/// Every service is made here, so every one of them reports going idle.
private func makeService(_ definition: LanguageServerDefinition, root: URL) -> LanguageService {
let service = LanguageService(definition: definition, root: root)
service.onIdle = { [weak self] in self?.scheduleIdleSweep() }
return service
}

private func scheduleIdleSweep(in delay: TimeInterval = LanguageServerRegistry.idleLimit) {
guard idleSweep == nil else { return }
idleSweep = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled, let self else { return }
shutdownIdleServices()
}
}

/// Stops everything that has been idle long enough, and puts the wait back
/// on for whatever is idle but not yet due.
private func shutdownIdleServices() {
idleSweep = nil
let now = Date()
var soonest: TimeInterval?

for (key, service) in services {
guard let idleSince = service.idleSince else { continue }
let idle = now.timeIntervalSince(idleSince)
if idle >= Self.idleLimit {
service.shutdown()
services[key] = nil
} else {
soonest = min(soonest ?? .greatestFiniteMagnitude, Self.idleLimit - idle)
}
}

// A server that went idle while this wait was running has its own left
// to serve; scheduling for the nearest keeps one wait for all of them.
if let soonest { scheduleIdleSweep(in: soonest) }
}

private static func key(root: URL, language: String) -> String {
"\(root.path)|\(language)"
}
Expand Down
38 changes: 37 additions & 1 deletion Sources/Workspace/LSP/LanguageService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,32 @@ final class LanguageService {
/// coordinator to decide whether it may send ranges or must send the file.
private(set) var syncKind: LSP.SyncKind = .full

/// Since when this server has been holding no documents at all, or nil
/// while it is holding one.
///
/// A language server is an expensive thing to leave running — a few hundred
/// megabytes each for the node ones, more for sourcekit-lsp — and nothing
/// used to stop one until the repository it belongs to was removed from the
/// sidebar. A day of looking at eight repositories left every server of all
/// eight resident. ``LanguageServerRegistry`` reads this and stops the ones
/// that have been idle long enough; opening a file in that language starts
/// a fresh one, which is what already happens the first time.
private(set) var idleSince: Date? {
didSet { if idleSince != nil { onIdle?() } }
}

/// Told when this server stops holding documents, so the registry can put
/// a wait on it rather than sweeping every server on a clock.
@ObservationIgnored var onIdle: (() -> Void)?

@ObservationIgnored private let connection: LSPConnection
@ObservationIgnored private var versions: [String: Int] = [:]
@ObservationIgnored private var openCount: [String: Int] = [:]
@ObservationIgnored private var openCount: [String: Int] = [:] {
didSet {
guard openCount.isEmpty != oldValue.isEmpty else { return }
idleSince = openCount.isEmpty ? Date() : nil
}
}
@ObservationIgnored private var diagnosticObservers: [ObjectIdentifier: (String, [LSP.Diagnostic]) -> Void] = [:]
@ObservationIgnored private var startTask: Task<Bool, Never>?
@ObservationIgnored private var settings: LSP.Value?
Expand Down Expand Up @@ -121,6 +144,9 @@ final class LanguageService {
)
}
status = .running
// A prewarmed server holds nothing yet, so its idle clock
// starts here. `open` clears it the moment a file arrives.
if openCount.isEmpty, idleSince == nil { idleSince = Date() }
return true
} catch {
status = .failed(error.localizedDescription)
Expand All @@ -134,8 +160,18 @@ final class LanguageService {
}

func shutdown() {
// Before the resets below, so emptying the documents does not report a
// server that is going away as one that has just gone idle.
onIdle = nil
Task { await connection.stop() }
status = .notStarted
// The handshake is what `startIfNeeded` remembers; without this a
// service stopped for being idle could never be started again.
startTask = nil
openCount = [:]
versions = [:]
diagnostics = [:]
idleSince = nil
}

// MARK: - Document sync
Expand Down
13 changes: 13 additions & 0 deletions Sources/Workspace/Models/PullRequestBuild.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ struct PullRequestBuild: Identifiable, Sendable, Hashable {
}
}

/// Whether this is a verdict rather than a stage on the way to one.
/// A run that has settled will not change again, so there is nothing
/// left to learn by asking the host about it a second time — see
/// `PullRequestDetailView.watchBuilds`. `unknown` is deliberately not
/// settled: it is a word this app failed to read, not a host saying
/// the run is over.
var isSettled: Bool {
switch self {
case .passed, .failed, .cancelled, .skipped: true
case .running, .pending, .unknown: false
}
}

/// Every host spells these differently, and one host spells them
/// differently in two places — Cloud reports a pipeline's outcome in
/// `state.result.name` but a commit status's in `state`. Matching on
Expand Down
76 changes: 61 additions & 15 deletions Sources/Workspace/Models/TerminalSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,21 +104,7 @@ final class TerminalSession: Identifiable {
self.title = title
self.view = GhosttySurfaceView()
view.onTitleChange = { [weak self] title in
guard let self else { return }
let wasWorking = isWorking
self.title = title
isWorking = Self.readsAsBusy(title)
titleChanges += 1
// `claude` renames the tab as it starts work on a prompt, which is
// exactly when a conversation that had no transcript may have got
// one — so a rename is the cue to go looking again.
watchForClaudeName()
// The spinner going away is the turn ending: whatever was being
// waited for is done, and the person who set it going is very
// likely somewhere else by now.
if wasWorking, !isWorking {
raiseAttention("Finished — waiting for you")
}
self?.titleArrived(title)
}
view.onClose = { [weak self] in
self?.isWorking = false
Expand All @@ -135,6 +121,64 @@ final class TerminalSession: Identifiable {
}
}

/// The newest name the shell has given itself, published or not.
@ObservationIgnored private var latestTitle: String?
/// The wait that will publish it; see ``titleArrived``.
@ObservationIgnored private var titlePublish: Task<Void, Never>?
/// How long a burst of renames is allowed to run before the tab catches
/// up. Short enough to read as immediate, long enough that a spinner is
/// several frames rather than several redraws.
private static let titleInterval = Duration.milliseconds(200)

/// A terminal renames itself far faster than anyone can read.
///
/// `claude` paints its spinner **into the tab title**, so a running turn is
/// a new name every frame — and every one of those used to be a write to an
/// observable property, which redraws the terminals list, the tab bar and
/// the viewer header. With several conversations going at once that was the
/// whole window redrawing continuously for a character nobody can follow,
/// and it got worse with every tab left running.
///
/// So the two halves are separated. What a name *means* is read straight
/// away — `isWorking` is what puts a banner up when a turn ends, and it
/// only changes at the two ends of one, so writing it on the change alone
/// costs nothing. The text itself is published on a slow tick, and the last
/// name of a burst is always published, so a tab never settles showing a
/// frame of the spinner.
private func titleArrived(_ title: String) {
// A program re-announcing the name it already has is not a rename.
guard title != latestTitle else { return }
latestTitle = title

let wasWorking = isWorking
let working = Self.readsAsBusy(title)
if working != wasWorking { isWorking = working }
titleChanges += 1
// `claude` renames the tab as it starts work on a prompt, which is
// exactly when a conversation that had no transcript may have got one —
// so a rename is the cue to go looking again.
watchForClaudeName()
// The spinner going away is the turn ending: whatever was being waited
// for is done, and the person who set it going is very likely somewhere
// else by now.
if wasWorking, !working {
raiseAttention("Finished — waiting for you")
}

guard titlePublish == nil else { return }
titlePublish = Task { [weak self] in
// `try?` here would swallow the cancellation and publish anyway,
// which is `terminate()` cancelling this and getting one last name
// written over the tab it is closing.
do { try await Task.sleep(for: Self.titleInterval) } catch { return }
guard let self else { return }
// Cleared before the write, so a rename arriving in the same turn
// starts the next wait rather than being dropped.
titlePublish = nil
if let latestTitle, latestTitle != self.title { self.title = latestTitle }
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// Whether a tab title reads as "the program in here is busy".
///
/// There is no other way to know. The app drives a real terminal, so the
Expand Down Expand Up @@ -311,6 +355,8 @@ final class TerminalSession: Identifiable {
isWorking = false
nameWatch?.cancel()
nameWatch = nil
titlePublish?.cancel()
titlePublish = nil
claudeStartupWatch?.cancel()
claudeStartupWatch = nil
view.close()
Expand Down
Loading
Loading