Skip to content

Commit 49f1b91

Browse files
authored
Merge pull request #201 from LockInTime/feat/195-response-pagination
feat(protocol): add bounded list pagination
2 parents 0f789de + 878cb8d commit 49f1b91

29 files changed

Lines changed: 2771 additions & 169 deletions

‎apps/headless/Host/AgentBridge.swift‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -321,12 +321,16 @@ extension BrowserWindowController {
321321
func agentQAReport() -> JSONValue { qaBridge.store.report() }
322322
func agentQAClear() -> JSONValue { qaBridge.clear() }
323323

324-
func agentConsole(level: String, limit: Int) -> JSONValue {
325-
qaBridge.store.console(level: level, limit: limit)
324+
func agentConsole(level: String, limit: Int, cursor: String?) throws -> JSONValue {
325+
try qaBridge.store.console(level: level, limit: limit, cursor: cursor)
326326
}
327327

328-
func agentNetwork(failedOnly: Bool, status: Int?, limit: Int) -> JSONValue {
329-
qaBridge.store.network(failedOnly: failedOnly, status: status, limit: limit)
328+
func agentNetwork(
329+
failedOnly: Bool, status: Int?, limit: Int, cursor: String?
330+
) throws -> JSONValue {
331+
try qaBridge.store.network(
332+
failedOnly: failedOnly, status: status, limit: limit, cursor: cursor
333+
)
330334
}
331335

332336
func agentNetworkDetail(requestID: String) -> JSONValue {
@@ -519,6 +523,16 @@ final class WebKitBrowserEngine: BrowserEngine {
519523
extension BrowserWindowController: BrowserEngineSession {
520524
var hostIsolated: Bool { isIsolatedSession }
521525

526+
func hostSessionMetadata() throws -> BrowserSessionPageMetadata {
527+
onMain {
528+
BrowserSessionPageMetadata(
529+
url: self.webView.url?.absoluteString,
530+
title: self.webView.title,
531+
lifecycle: self.webView.isLoading ? .navigating : .available
532+
)
533+
}
534+
}
535+
522536
func hostEnableAgentControl() { onMain { self.enableAgentControl() } }
523537
func hostVisit(_ url: URL) throws -> JSONValue { try agentVisit(url) }
524538
func hostInspect(parameters: [String: JSONValue]) throws -> JSONValue {
@@ -572,11 +586,11 @@ extension BrowserWindowController: BrowserEngineSession {
572586
}
573587
func hostQAReport() throws -> JSONValue { agentQAReport() }
574588
func hostQAClear() throws -> JSONValue { agentQAClear() }
575-
func hostConsole(level: String, limit: Int) throws -> JSONValue {
576-
agentConsole(level: level, limit: limit)
589+
func hostConsole(level: String, limit: Int, cursor: String?) throws -> JSONValue {
590+
try agentConsole(level: level, limit: limit, cursor: cursor)
577591
}
578-
func hostNetwork(failedOnly: Bool, status: Int?, limit: Int) throws -> JSONValue {
579-
agentNetwork(failedOnly: failedOnly, status: status, limit: limit)
592+
func hostNetwork(failedOnly: Bool, status: Int?, limit: Int, cursor: String?) throws -> JSONValue {
593+
try agentNetwork(failedOnly: failedOnly, status: status, limit: limit, cursor: cursor)
580594
}
581595
func hostNetworkDetail(requestID: String) throws -> JSONValue {
582596
agentNetworkDetail(requestID: requestID)

‎apps/headless/LinuxHost/BrowserProcess.swift‎

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,7 @@ final class LinuxBrowserSession: @unchecked Sendable {
616616
private var mainFrameID: String?
617617
private var lastSafeURL: String?
618618
private var navigationRecoveryPending = false
619+
private var navigationInProgress = false
619620
private var isolatedContextID: Int?
620621
private let mockLock = NSLock()
621622
private var networkMocks: [NetworkMock] = []
@@ -671,12 +672,30 @@ final class LinuxBrowserSession: @unchecked Sendable {
671672
}
672673

673674
func visit(_ url: URL) throws -> JSONValue {
674-
navigationLock.lock(); lastSafeURL = url.absoluteString; navigationLock.unlock()
675+
navigationLock.lock()
676+
lastSafeURL = url.absoluteString
677+
navigationInProgress = true
678+
navigationLock.unlock()
675679
pauseRecordingCapture()
676680
_ = try command("Page.navigate", parameters: ["url": url.absoluteString])
677681
return try wait(parameters: ["settled": .bool(true), "timeoutMs": .number(20_000)])
678682
}
679683

684+
func sessionMetadata() throws -> BrowserSessionPageMetadata {
685+
let history = try command("Page.getNavigationHistory", timeoutMilliseconds: 2_000)
686+
let currentIndex = (history["currentIndex"] as? NSNumber)?.intValue ?? -1
687+
let entries = history["entries"] as? [[String: Any]] ?? []
688+
let entry = entries.indices.contains(currentIndex) ? entries[currentIndex] : nil
689+
navigationLock.lock()
690+
let navigating = navigationInProgress
691+
navigationLock.unlock()
692+
return BrowserSessionPageMetadata(
693+
url: entry?["url"] as? String,
694+
title: entry?["title"] as? String,
695+
lifecycle: navigating ? .navigating : .available
696+
)
697+
}
698+
680699
func inspect(parameters: [String: JSONValue]) throws -> JSONValue {
681700
var options: [String: Any] = [
682701
"context": parameters["context"]?.stringValue ?? "full",
@@ -965,12 +984,16 @@ final class LinuxBrowserSession: @unchecked Sendable {
965984
return diagnostics.report()
966985
}
967986

968-
func console(level: String, limit: Int) -> JSONValue {
969-
diagnostics.console(level: level, limit: limit)
987+
func console(level: String, limit: Int, cursor: String?) throws -> JSONValue {
988+
try diagnostics.console(level: level, limit: limit, cursor: cursor)
970989
}
971990

972-
func network(failedOnly: Bool, status: Int?, limit: Int) -> JSONValue {
973-
diagnostics.network(failedOnly: failedOnly, status: status, limit: limit)
991+
func network(
992+
failedOnly: Bool, status: Int?, limit: Int, cursor: String?
993+
) throws -> JSONValue {
994+
try diagnostics.network(
995+
failedOnly: failedOnly, status: status, limit: limit, cursor: cursor
996+
)
974997
}
975998

976999
func networkDetail(requestID: String) -> JSONValue {
@@ -1106,6 +1129,22 @@ final class LinuxBrowserSession: @unchecked Sendable {
11061129
}
11071130
case "Page.frameStartedLoading":
11081131
pauseRecordingCapture()
1132+
if let frameID = parameters["frameId"] as? String {
1133+
navigationLock.lock()
1134+
if mainFrameID == nil { mainFrameID = frameID }
1135+
if mainFrameID == frameID { navigationInProgress = true }
1136+
navigationLock.unlock()
1137+
}
1138+
case "Page.frameStoppedLoading":
1139+
if let frameID = parameters["frameId"] as? String {
1140+
navigationLock.lock()
1141+
if mainFrameID == frameID { navigationInProgress = false }
1142+
navigationLock.unlock()
1143+
}
1144+
case "Page.loadEventFired":
1145+
navigationLock.lock()
1146+
navigationInProgress = false
1147+
navigationLock.unlock()
11091148
case "Page.frameRequestedNavigation":
11101149
pauseRecordingCapture()
11111150
if let frameID = parameters["frameId"] as? String,

‎apps/headless/LinuxHost/main.swift‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ final class ChromiumBrowserEngineSession: BrowserEngineSession {
7777

7878
var hostIsolated: Bool { browserSession.isIsolated }
7979

80+
func hostSessionMetadata() throws -> BrowserSessionPageMetadata {
81+
try browserSession.sessionMetadata()
82+
}
83+
8084
func hostVisit(_ url: URL) throws -> JSONValue { try browserSession.visit(url) }
8185
func hostInspect(parameters: [String: JSONValue]) throws -> JSONValue {
8286
try browserSession.inspect(parameters: parameters)
@@ -136,11 +140,11 @@ final class ChromiumBrowserEngineSession: BrowserEngineSession {
136140
}
137141
func hostQAReport() throws -> JSONValue { try browserSession.qaReport() }
138142
func hostQAClear() throws -> JSONValue { browserSession.diagnostics.clear() }
139-
func hostConsole(level: String, limit: Int) throws -> JSONValue {
140-
browserSession.console(level: level, limit: limit)
143+
func hostConsole(level: String, limit: Int, cursor: String?) throws -> JSONValue {
144+
try browserSession.console(level: level, limit: limit, cursor: cursor)
141145
}
142-
func hostNetwork(failedOnly: Bool, status: Int?, limit: Int) throws -> JSONValue {
143-
browserSession.network(failedOnly: failedOnly, status: status, limit: limit)
146+
func hostNetwork(failedOnly: Bool, status: Int?, limit: Int, cursor: String?) throws -> JSONValue {
147+
try browserSession.network(failedOnly: failedOnly, status: status, limit: limit, cursor: cursor)
144148
}
145149
func hostNetworkDetail(requestID: String) throws -> JSONValue {
146150
browserSession.networkDetail(requestID: requestID)

‎apps/headless/Sources/HeadlessCLI/main.swift‎

Lines changed: 134 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,81 @@ private struct HostLauncher {
3030

3131
let client = LocalSocketClient()
3232

33+
private final class LogCapture {
34+
let store: HostLogStore
35+
let process: Process
36+
let writer: FileHandle
37+
38+
init(environment: [String: String]) throws {
39+
store = try HostLogStore(environment: environment)
40+
try store.prepare()
41+
let pipe = Pipe()
42+
process = Process()
43+
process.executableURL = try Self.runningCLIURL()
44+
process.arguments = ["__host-log-writer"]
45+
process.environment = [
46+
"HEADLESS_INTERNAL_LOG_WRITER": "1",
47+
"HEADLESS_INTERNAL_LOG_PATH": store.url.path,
48+
"HEADLESS_INTERNAL_LOG_MAX_BYTES": String(store.maximumBytes),
49+
]
50+
process.standardInput = pipe.fileHandleForReading
51+
process.standardOutput = FileHandle.nullDevice
52+
process.standardError = FileHandle.nullDevice
53+
writer = pipe.fileHandleForWriting
54+
do {
55+
try process.run()
56+
try pipe.fileHandleForReading.close()
57+
} catch {
58+
try? pipe.fileHandleForReading.close()
59+
try? pipe.fileHandleForWriting.close()
60+
throw error
61+
}
62+
}
63+
64+
func closeParentWriter() {
65+
try? writer.close()
66+
}
67+
68+
func waitForDrain() {
69+
closeParentWriter()
70+
let deadline = Date().addingTimeInterval(1)
71+
while process.isRunning, Date() < deadline {
72+
Thread.sleep(forTimeInterval: 0.01)
73+
}
74+
if process.isRunning {
75+
process.terminate()
76+
let terminationDeadline = Date().addingTimeInterval(1)
77+
while process.isRunning, Date() < terminationDeadline {
78+
Thread.sleep(forTimeInterval: 0.01)
79+
}
80+
}
81+
if process.isRunning { _ = kill(process.processIdentifier, SIGKILL) }
82+
process.waitUntilExit()
83+
}
84+
85+
var diagnostic: HostLaunchDiagnostic {
86+
HostLaunchDiagnostic(path: store.url.path, tail: store.diagnosticTail())
87+
}
88+
89+
private static func runningCLIURL() throws -> URL {
90+
#if os(Linux)
91+
let candidate = URL(fileURLWithPath: "/proc/self/exe").resolvingSymlinksInPath()
92+
#else
93+
var requiredSize: UInt32 = 0
94+
_ = _NSGetExecutablePath(nil, &requiredSize)
95+
var buffer = [CChar](repeating: 0, count: Int(requiredSize))
96+
guard _NSGetExecutablePath(&buffer, &requiredSize) == 0 else {
97+
throw HostLogError.operationFailed("writer executable resolution")
98+
}
99+
let candidate = URL(fileURLWithPath: String(cString: buffer)).standardizedFileURL
100+
#endif
101+
guard FileManager.default.isExecutableFile(atPath: candidate.path) else {
102+
throw HostLogError.operationFailed("writer executable resolution")
103+
}
104+
return candidate
105+
}
106+
}
107+
33108
func ping() -> CommandResponse? {
34109
try? client.send(CommandRequest(command: .ping), timeout: 0.5)
35110
}
@@ -75,22 +150,17 @@ private struct HostLauncher {
75150
environment.removeValue(forKey: headlessNavigationAllowlistEnvironmentKey)
76151
}
77152
process.environment = environment
153+
let logCapture = try LogCapture(environment: environment)
78154
let ownerPipe = supervised ? Pipe() : nil
79155
process.standardInput = ownerPipe?.fileHandleForReading ?? FileHandle.nullDevice
80-
if let hostLog = environment["HEADLESS_HOST_LOG"], hostLog.hasPrefix("/") {
81-
let logURL = URL(fileURLWithPath: hostLog)
82-
FileManager.default.createFile(atPath: logURL.path, contents: nil)
83-
let handle = try FileHandle(forWritingTo: logURL)
84-
process.standardOutput = handle
85-
process.standardError = handle
86-
} else {
87-
process.standardOutput = FileHandle.nullDevice
88-
process.standardError = FileHandle.nullDevice
89-
}
156+
process.standardOutput = logCapture.writer
157+
process.standardError = logCapture.writer
90158
do {
91159
try process.run()
160+
logCapture.closeParentWriter()
92161
try ownerPipe?.fileHandleForReading.close()
93162
} catch {
163+
logCapture.waitForDrain()
94164
try? ownerPipe?.fileHandleForReading.close()
95165
try? ownerPipe?.fileHandleForWriting.close()
96166
throw error
@@ -117,13 +187,15 @@ private struct HostLauncher {
117187
if !process.isRunning {
118188
try? ownerPipe?.fileHandleForWriting.close()
119189
process.waitUntilExit()
120-
throw HostLaunchError.exited(process.terminationStatus)
190+
logCapture.waitForDrain()
191+
throw HostLaunchError.exited(process.terminationStatus, logCapture.diagnostic)
121192
}
122193
Thread.sleep(forTimeInterval: 0.05)
123194
} while Date() < deadline
124195
try? ownerPipe?.fileHandleForWriting.close()
125196
terminateAndReap(process)
126-
throw HostLaunchError.timedOut
197+
logCapture.waitForDrain()
198+
throw HostLaunchError.timedOut(logCapture.diagnostic)
127199
}
128200

129201
func waitForSupervisedHost(_ launch: Launch) -> Int32 {
@@ -228,12 +300,22 @@ private struct HostLauncher {
228300
}
229301
}
230302

303+
private struct HostLaunchDiagnostic {
304+
let path: String
305+
let tail: String?
306+
307+
var description: String {
308+
guard let tail, !tail.isEmpty else { return "Host log: \(path)" }
309+
return "Host log: \(path)\nRecent host output:\n\(tail)"
310+
}
311+
}
312+
231313
private enum HostLaunchError: Error, CustomStringConvertible {
232314
case notFound
233315
case alreadyRunning
234316
case ownershipMismatch
235-
case timedOut
236-
case exited(Int32)
317+
case timedOut(HostLaunchDiagnostic)
318+
case exited(Int32, HostLaunchDiagnostic)
237319
case allowlistMismatch(running: [String], requested: [String])
238320

239321
var description: String {
@@ -243,8 +325,10 @@ private enum HostLaunchError: Error, CustomStringConvertible {
243325
return "A shared Headless host is already running. Stop it before starting a supervised host."
244326
case .ownershipMismatch:
245327
return "A different Headless host answered during supervised startup."
246-
case .timedOut: return "Headless host did not become ready within 8 seconds."
247-
case .exited(let status): return "Headless host exited during startup (status \(status))."
328+
case .timedOut(let diagnostic):
329+
return "Headless host did not become ready within 8 seconds.\n\(diagnostic.description)"
330+
case .exited(let status, let diagnostic):
331+
return "Headless host exited during startup (status \(status)).\n\(diagnostic.description)"
248332
case .allowlistMismatch(let running, let requested):
249333
let runningText = running.isEmpty ? "unrestricted" : running.joined(separator: ", ")
250334
return "The running host navigation allowlist (\(runningText)) does not match (\(requested.joined(separator: ", "))). Run `headless stop` first."
@@ -321,7 +405,30 @@ private enum CredentialBrokerLaunchError: Error, CustomStringConvertible {
321405
}
322406
}
323407

408+
private func runInternalHostLogWriterIfRequested() -> Bool {
409+
guard Array(CommandLine.arguments.dropFirst()) == ["__host-log-writer"] else { return false }
410+
let environment = ProcessInfo.processInfo.environment
411+
guard environment["HEADLESS_INTERNAL_LOG_WRITER"] == "1",
412+
let path = environment["HEADLESS_INTERNAL_LOG_PATH"], path.hasPrefix("/"),
413+
let rawMaximum = environment["HEADLESS_INTERNAL_LOG_MAX_BYTES"],
414+
let maximum = Int(rawMaximum) else {
415+
fputs("headless: internal log writer authorization failed\n", stderr)
416+
exit(64)
417+
}
418+
do {
419+
let store = try HostLogStore(
420+
environment: ["HEADLESS_HOST_LOG": path], maximumBytes: maximum
421+
)
422+
try store.consume(.standardInput)
423+
} catch {
424+
fputs("headless: internal log writer failed\n", stderr)
425+
exit(74)
426+
}
427+
return true
428+
}
429+
324430
do {
431+
if runInternalHostLogWriterIfRequested() { exit(0) }
325432
let invocation = try CLIParser().parse(Array(CommandLine.arguments.dropFirst()))
326433
if let local = invocation.local {
327434
switch local {
@@ -342,6 +449,10 @@ do {
342449
"supported": .bool(true), "transport": .string("native-webkit"),
343450
]))
344451
#endif
452+
case .doctor:
453+
let report = try HeadlessDoctor().run()
454+
printJSON(report.document)
455+
if report.hasFailures { exit(69) }
345456
case .start(let presentation, let allowlist, let supervised):
346457
let launch = try HostLauncher().start(
347458
presentation: presentation, allowlist: allowlist, supervised: supervised
@@ -423,6 +534,13 @@ do {
423534
)
424535
try? printResponse(response)
425536
exit(69)
537+
} catch let error as HostLogError {
538+
let response = CommandResponse.failure(
539+
id: "unknown", code: "HOST_LOG_UNAVAILABLE", message: error.description,
540+
suggestion: "Check the private runtime directory or HEADLESS_HOST_LOG path."
541+
)
542+
try? printResponse(response)
543+
exit(69)
426544
} catch let error as SettingsError {
427545
let code: String
428546
switch error {

0 commit comments

Comments
 (0)