• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / agterm / 28814082776

06 Jul 2026 06:28PM UTC coverage: 98.913% (+0.001%) from 98.912%
28814082776

Pull #151

github

web-flow
Merge 70c190c76 into 0b2da28ff
Pull Request #151: fix(core): support agtermctl on Linux

8 of 9 new or added lines in 1 file covered. (88.89%)

3 existing lines in 1 file now uncovered.

3641 of 3681 relevant lines covered (98.91%)

8167709.69 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.24
/agtermCore/Sources/agtermctlKit/SocketClient.swift
1
#if canImport(Darwin)
2
import Darwin
3
#elseif canImport(Glibc)
4
import Glibc
5
#endif
6
import Foundation
7
import agtermCore
8

9
/// A failure talking to the control socket (connect/write/read/decode), distinct from a server-side
10
/// `{"ok":false}` response (which is a valid decoded `ControlResponse`).
11
struct SocketClientError: Error, CustomStringConvertible {
12
    let description: String
13
    init(_ description: String) { self.description = description }
1✔
14
}
15

16
/// A blocking, one-request-per-connection client for the agterm control socket: connect to a unix domain
17
/// socket, write the request line, read the single response line, decode it.
18
struct SocketClient {
19
    let path: String
20

21
    /// 64 MiB cap on a response line. Requests stay small; a `session.text --all` response carries the
22
    /// whole scrollback and can reach several MiB. It comes from our own server, so the cap only guards
23
    /// against a runaway read (ghostty scrollback tops out near 10 MiB).
24
    private static let maxLineBytes = 64 << 20
25

26
    /// Connect, send `request` as one newline-terminated JSON line, read the response line, decode it.
27
    func send(_ request: ControlRequest) throws -> ControlResponse {
13✔
28
        let fd = try connect()
13✔
29
        defer { close(fd) }
12✔
30

12✔
31
        var data = try JSONEncoder().encode(request)
12✔
32
        data.append(UInt8(ascii: "\n"))
12✔
33
        try Self.writeAll(fd, data)
12✔
34

12✔
35
        guard let line = Self.readLine(fd) else {
12✔
36
            throw SocketClientError("no response from \(path)")
×
37
        }
12✔
38
        do {
12✔
39
            return try JSONDecoder().decode(ControlResponse.self, from: line)
12✔
40
        } catch {
12✔
41
            throw SocketClientError("could not decode response: \(error.localizedDescription)")
×
42
        }
×
43
    }
12✔
44

45
    /// Open and connect a `AF_UNIX` stream socket to `path`.
46
    private func connect() throws -> Int32 {
13✔
47
        guard path.utf8.count < 104 else {
13✔
NEW
48
            throw SocketClientError("socket path too long (\(path.utf8.count) bytes): \(path)")
×
49
        }
13✔
50
        #if canImport(Darwin)
51
        let fd = socket(AF_UNIX, SOCK_STREAM, 0)
13✔
52
        #else
53
        let fd = socket(AF_UNIX, Int32(SOCK_STREAM.rawValue), 0)
54
        #endif
55
        guard fd >= 0 else { throw SocketClientError("socket() failed: \(String(cString: strerror(errno)))") }
13✔
56

13✔
57
        var addr = sockaddr_un()
13✔
58
        addr.sun_family = sa_family_t(AF_UNIX)
13✔
59
        let pathBytes = path.utf8CString
13✔
60
        withUnsafeMutablePointer(to: &addr.sun_path) { dst in
13✔
61
            dst.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { buf in
13✔
62
                pathBytes.withUnsafeBufferPointer { src in
13✔
63
                    buf.update(from: src.baseAddress!, count: src.count)
13✔
64
                }
13✔
65
            }
13✔
66
        }
13✔
67

13✔
68
        let result = withUnsafePointer(to: &addr) { ptr in
13✔
69
            ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
13✔
70
                systemConnect(fd, sa, socklen_t(MemoryLayout<sockaddr_un>.size))
13✔
71
            }
13✔
72
        }
13✔
73
        guard result == 0 else {
13✔
74
            let message = String(cString: strerror(errno))
1✔
75
            close(fd)
1✔
76
            throw SocketClientError("connect(\(path)) failed: \(message) — is agterm running?")
1✔
77
        }
12✔
78
        return fd
12✔
79
    }
13✔
80

81
    /// Write all of `data` to `fd`, looping over short writes.
82
    private static func writeAll(_ fd: Int32, _ data: Data) throws {
12✔
83
        try data.withUnsafeBytes { raw in
12✔
84
            var offset = 0
12✔
85
            let base = raw.bindMemory(to: UInt8.self).baseAddress!
12✔
86
            while offset < data.count {
24✔
87
                let n = write(fd, base + offset, data.count - offset)
12✔
88
                if n <= 0 { throw SocketClientError("write failed: \(String(cString: strerror(errno)))") }
12✔
89
                offset += n
12✔
90
            }
12✔
91
        }
12✔
92
    }
12✔
93

94
    /// Read up to (and excluding) the first newline, capping at `maxLineBytes`. Returns nil on
95
    /// EOF-before-newline, error, or cap exceeded. Reads in 64 KiB chunks so a multi-MB
96
    /// `session.text --all` response takes a handful of syscalls instead of one per byte.
97
    private static func readLine(_ fd: Int32) -> Data? {
12✔
98
        var buffer = Data()
12✔
99
        var chunk = [UInt8](repeating: 0, count: 64 * 1024)
12✔
100
        while true {
427✔
101
            let n = chunk.withUnsafeMutableBytes { read(fd, $0.baseAddress, $0.count) }
427✔
102
            if n == 0 { return buffer.isEmpty ? nil : buffer }
427✔
103
            if n < 0 { return nil }
427✔
104
            if let idx = chunk[0..<n].firstIndex(of: UInt8(ascii: "\n")) {
427✔
105
                buffer.append(contentsOf: chunk[0..<idx])
12✔
106
                return buffer
12✔
107
            }
415✔
108
            buffer.append(contentsOf: chunk[0..<n])
415✔
109
            if buffer.count > maxLineBytes { return nil }
415✔
110
        }
415✔
UNCOV
111
    }
×
112

113
    /// Print a response: the raw JSON line with `json: true`, otherwise a human-readable summary. An error
114
    /// response (`ok == false`, non-`--json`) goes to stderr; everything else to stdout.
115
    static func printResponse(_ response: ControlResponse, json: Bool, echoID: Bool = false) {
4✔
116
        if !json, !response.ok {
4✔
117
            FileHandle.standardError.write(Data((formatResponse(response, json: false) + "\n").utf8))
1✔
118
            return
1✔
119
        }
3✔
120
        print(formatResponse(response, json: json, echoID: echoID))
3✔
121
    }
3✔
122

123
    /// Render a response to a single string (no trailing newline): the raw JSON line with `json: true`,
124
    /// otherwise a human-readable summary — an `error:` line, the tree listing, the selected text, the
125
    /// affected id (only when `echoID`, i.e. for the create commands), or a bare `ok`. Pure so it can be
126
    /// unit-tested directly; `printResponse` routes it to stdout/stderr.
127
    static func formatResponse(_ response: ControlResponse, json: Bool, echoID: Bool = false) -> String {
22✔
128
        if json {
22✔
129
            if let data = try? JSONEncoder().encode(response), let line = String(data: data, encoding: .utf8) {
2✔
130
                return line
2✔
131
            }
2✔
UNCOV
132
            return ""
×
133
        }
20✔
134
        if !response.ok {
20✔
135
            return "error: " + (response.error ?? "unknown error")
3✔
136
        }
17✔
137
        if let tree = response.result?.tree {
17✔
138
            return formatTree(tree)
3✔
139
        }
14✔
140
        if let windows = response.result?.windows {
14✔
141
            return formatWindows(windows)
2✔
142
        }
12✔
143
        if let themes = response.result?.themes {
12✔
144
            return formatThemes(themes, current: response.result?.theme)
2✔
145
        }
10✔
146
        if let text = response.result?.text {
10✔
147
            return text
1✔
148
        }
9✔
149
        if let exitCode = response.result?.exitCode {
9✔
UNCOV
150
            return "exit \(exitCode)"
×
151
        }
9✔
152
        if let count = response.result?.count {
9✔
153
            // keymap.reload reports its parse-diagnostic count; 0 reads as a clean reload.
2✔
154
            return count == 0 ? "ok" : "\(count) diagnostic(s)"
2✔
155
        }
7✔
156
        if let ratio = response.result?.ratio {
7✔
157
            // session.resize echoes the applied (clamped) left-pane fraction, scriptable as a bare number.
1✔
158
            return String(format: "%.3f", ratio)
1✔
159
        }
6✔
160
        if echoID, let id = response.result?.id {
6✔
161
            return id
2✔
162
        }
4✔
163
        return "ok"
4✔
164
    }
22✔
165

166
    /// Render the `theme.list` payload as one theme name per line, the current theme marked with `* `
167
    /// and a leading "default ghostty" entry for the no-theme (ghostty built-in) case (no trailing newline).
168
    static func formatThemes(_ themes: [String], current: String?) -> String {
2✔
169
        func line(_ name: String?, _ label: String) -> String { (name == current ? "* " : "  ") + label }
5✔
170
        return ([line(nil, "default ghostty")] + themes.map { line($0, $0) }).joined(separator: "\n")
3✔
171
    }
2✔
172

173
    /// Render the `window.list` payload as one `id  name  [open]  [active]` line per window (no trailing
174
    /// newline). Closed/inactive windows still list, with the bracket tag absent.
175
    static func formatWindows(_ windows: [ControlWindowNode]) -> String {
2✔
176
        windows.map { window in
4✔
177
            let tags = (window.open ? " [open]" : "") + (window.active ? " [active]" : "")
4✔
178
            return "\(window.id)  \(window.name)\(tags)"
4✔
179
        }.joined(separator: "\n")
4✔
180
    }
2✔
181

182
    /// Render a tree as an indented workspace → session listing (no trailing newline).
183
    private static func formatTree(_ tree: ControlTree) -> String {
3✔
184
        var lines: [String] = []
3✔
185
        for workspace in tree.workspaces {
3✔
186
            let mark = workspace.active ? "*" : " "
3✔
187
            lines.append("\(mark) \(workspace.name)  [\(workspace.id)]")
3✔
188
            for session in workspace.sessions {
3✔
189
                let smark = session.active ? "*" : " "
3✔
190
                let tags = (session.split ? " (split)" : "") + (session.overlay ? " (overlay)" : "")
3✔
191
                    + (session.scratch ? " (scratch)" : "")
3✔
192
                let titleSuffix = session.title.map { "  title: \($0)" } ?? ""
3✔
193
                lines.append("  \(smark) \(session.name)\(tags)  [\(session.id)]  \(session.cwd)\(titleSuffix)")
3✔
194
            }
3✔
195
        }
3✔
196
        return lines.joined(separator: "\n")
3✔
197
    }
3✔
198
}
199

200
private func systemConnect(_ fd: Int32, _ addr: UnsafePointer<sockaddr>, _ len: socklen_t) -> Int32 {
13✔
201
    #if canImport(Darwin)
202
    return Darwin.connect(fd, addr, len)
13✔
203
    #else
204
    return Glibc.connect(fd, addr, len)
205
    #endif
206
}
13✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc