Skip to content

Commit c5b4069

Browse files
authored
Merge pull request #83 from LockInTime/t3code/bounded-responses
fix: keep qa report and artifact listings inside the protocol frame
2 parents 27d2eb6 + 67c8282 commit c5b4069

6 files changed

Lines changed: 153 additions & 7 deletions

File tree

apps/headless/Sources/HeadlessProtocol/Artifacts.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,21 @@ public final class ArtifactStore: @unchecked Sendable {
173173
guard case .object(let lhs) = left, case .object(let rhs) = right else { return false }
174174
return (lhs["createdAt"]?.numberValue ?? 0) > (rhs["createdAt"]?.numberValue ?? 0)
175175
}
176-
return .object(["directory": .string(rootURL.path), "artifacts": .array(artifacts)])
176+
// The store grows without bound across a long session, and the listing
177+
// has to survive the 1 MiB protocol frame. Newest first, bounded, and
178+
// explicit about what was left out.
179+
let listed = Array(artifacts.prefix(Self.maximumListedArtifacts))
180+
return .object([
181+
"directory": .string(rootURL.path),
182+
"artifacts": .array(listed),
183+
"total": .number(Double(artifacts.count)),
184+
"omitted": .number(Double(artifacts.count - listed.count)),
185+
"truncated": .bool(listed.count < artifacts.count),
186+
])
177187
}
178188

189+
private static let maximumListedArtifacts = 250
190+
179191
private static let listedExtensions: Set<String> =
180192
ScreenshotFormat.artifactExtensions
181193
.union(RecordingFormat.artifactExtensions)

apps/headless/Sources/HeadlessProtocol/Diagnostics.swift

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ public final class QADiagnosticStore: @unchecked Sendable {
55
private var events: [JSONValue] = []
66
private var didTruncate = false
77
private let maximumEvents = 500
8+
/// A full report of 500 events, each carrying up to a 4 KiB message and an
9+
/// 8 KiB URL, can exceed the 1 MiB protocol frame. Encoding would then
10+
/// throw on the way out and the agent would receive a misleading
11+
/// INVALID_REQUEST for a request that was perfectly valid, so the arrays
12+
/// are bounded here and the response says what it dropped.
13+
private let maximumReportEventBytes = 384 * 1_024
14+
/// Issues are derived from the same events and carry the same message and
15+
/// URL, so a count cap alone is not a size cap: 100 issues built from 4 KiB
16+
/// messages and 8 KiB URLs is over a megabyte on its own.
17+
private let maximumReportIssueBytes = 192 * 1_024
18+
private let maximumReportIssues = 100
819

920
public init() {}
1021

@@ -72,6 +83,14 @@ public final class QADiagnosticStore: @unchecked Sendable {
7283
return object["severity"] == .string("error")
7384
}.count
7485
let warnings = issues.count - errors
86+
// Counts come from the whole snapshot; only the arrays are bounded, so
87+
// the summary stays accurate even when the payload is trimmed.
88+
let boundedIssues = valuesWithinBudget(
89+
Array(issues.suffix(maximumReportIssues)), bytes: maximumReportIssueBytes
90+
)
91+
let boundedEvents = valuesWithinBudget(snapshot, bytes: maximumReportEventBytes)
92+
let omittedIssues = issues.count - boundedIssues.count
93+
let omittedEvents = snapshot.count - boundedEvents.count
7594
return .object([
7695
"summary": .object([
7796
"events": .number(Double(snapshot.count)),
@@ -83,12 +102,32 @@ public final class QADiagnosticStore: @unchecked Sendable {
83102
"errors": .number(Double(errors)),
84103
"warnings": .number(Double(warnings)),
85104
]),
86-
"issues": .array(issues),
87-
"events": .array(snapshot),
88-
"truncated": .bool(wasTruncated),
105+
"issues": .array(boundedIssues),
106+
"events": .array(boundedEvents),
107+
"omitted": .object([
108+
"issues": .number(Double(omittedIssues)),
109+
"events": .number(Double(omittedEvents)),
110+
]),
111+
"truncated": .bool(wasTruncated || omittedIssues > 0 || omittedEvents > 0),
89112
])
90113
}
91114

115+
/// Drops the oldest entries until the array fits its budget. Newest entries
116+
/// are the ones an agent is diagnosing, so they are the ones kept.
117+
private func valuesWithinBudget(_ values: [JSONValue], bytes: Int) -> [JSONValue] {
118+
var kept = values
119+
while kept.count > 1, encodedByteCount(kept) > bytes {
120+
kept.removeFirst(max(1, kept.count / 8))
121+
}
122+
if kept.count == 1, encodedByteCount(kept) > bytes { return [] }
123+
return kept
124+
}
125+
126+
private func encodedByteCount(_ values: [JSONValue]) -> Int {
127+
guard let data = try? ProtocolCodec.encoder.encode(JSONValue.array(values)) else { return 0 }
128+
return data.count
129+
}
130+
92131
public func console(level: String, limit: Int) -> JSONValue {
93132
lock.lock(); let snapshot = events; lock.unlock()
94133
let items = snapshot.filter { event in

apps/headless/Sources/HeadlessProtocol/Transport.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,21 @@ public final class LocalSocketServer: @unchecked Sendable {
216216
} else {
217217
response = requestQueue.sync { handler(request) }
218218
}
219-
try writeAll(try ProtocolCodec.encodeLine(response), to: fd)
219+
// A response that cannot be framed is a response problem, not a bad
220+
// request. Encoding it inside the catch below would report
221+
// INVALID_REQUEST for a request the host accepted and executed.
222+
let payload: Data
223+
do {
224+
payload = try ProtocolCodec.encodeLine(response)
225+
} catch let codecError as CodecError {
226+
payload = try ProtocolCodec.encodeLine(CommandResponse.failure(
227+
id: request.id,
228+
code: "RESPONSE_TOO_LARGE",
229+
message: String(describing: codecError),
230+
suggestion: "Narrow the request with --limit, or run `headless qa clear` to drop collected diagnostics."
231+
))
232+
}
233+
try writeAll(payload, to: fd)
220234
} catch {
221235
let response = CommandResponse.failure(
222236
id: "unknown",

apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,71 @@ struct ProtocolTests {
653653
try expect(!serialized.contains("secret@"), "diagnostics must redact URL credentials")
654654
}
655655

656+
static func responsesFitTheProtocolFrame() throws {
657+
// 500 events each carrying a 4 KiB message is roughly 2 MB — twice the
658+
// frame. Before the response bound this encoded past the limit and the
659+
// agent saw INVALID_REQUEST for a valid `qa report`.
660+
let store = QADiagnosticStore()
661+
let wide = String(repeating: "d", count: 4_096)
662+
for _ in 0..<500 {
663+
store.append(kind: "console", level: "error", message: wide, url: "https://example.com/\(wide)")
664+
}
665+
let report = store.report()
666+
let encoded = try ProtocolCodec.encodeLine(
667+
CommandResponse.success(id: "report", result: report)
668+
)
669+
try expect(
670+
encoded.count <= headlessMaximumMessageBytes,
671+
"a full diagnostic report must fit the protocol frame"
672+
)
673+
guard case .object(let object) = report else { throw TestFailure(description: "report shape") }
674+
try expect(object["truncated"] == .bool(true), "a bounded report should report truncation")
675+
guard case .object(let summary)? = object["summary"],
676+
case .object(let omitted)? = object["omitted"],
677+
case .array(let events)? = object["events"],
678+
case .array(let issues)? = object["issues"] else {
679+
throw TestFailure(description: "report bounds")
680+
}
681+
try expect(summary["events"] == .number(500), "summary counts should describe every event")
682+
try expect((omitted["events"]?.numberValue ?? 0) > 0, "omitted events should be counted")
683+
try expect(
684+
events.count + Int(omitted["events"]?.numberValue ?? 0) == 500,
685+
"kept plus omitted events should account for the whole buffer"
686+
)
687+
// Issues carry the same message and URL as the events they describe, so
688+
// a count cap is not a size cap — bounding them by bytes is what keeps
689+
// the report inside the frame.
690+
try expect(
691+
issues.count + Int(omitted["issues"]?.numberValue ?? 0)
692+
== Int(summary["issues"]?.numberValue ?? 0),
693+
"kept plus omitted issues should account for every issue"
694+
)
695+
}
696+
697+
static func artifactListingStaysBounded() throws {
698+
let root = "/tmp/headless-artifact-bound-\(UUID().uuidString)"
699+
defer { try? FileManager.default.removeItem(atPath: root) }
700+
let store = try ArtifactStore(environment: ["HEADLESS_ARTIFACT_DIR": root])
701+
for index in 0..<260 {
702+
_ = try store.write(Data("x".utf8), requestedName: "bound-\(index).json", extension: "json", prefix: "bound")
703+
}
704+
guard case .object(let listing) = try store.list(),
705+
case .array(let artifacts)? = listing["artifacts"] else {
706+
throw TestFailure(description: "artifact listing")
707+
}
708+
try expect(artifacts.count == 250, "artifact listing should stay bounded")
709+
try expect(listing["total"] == .number(260), "artifact listing should report the true total")
710+
try expect(listing["omitted"] == .number(10), "artifact listing should report what it left out")
711+
try expect(listing["truncated"] == .bool(true), "a bounded artifact listing is truncated")
712+
let encoded = try ProtocolCodec.encodeLine(
713+
CommandResponse.success(id: "artifacts", result: try store.list())
714+
)
715+
try expect(
716+
encoded.count <= headlessMaximumMessageBytes,
717+
"an artifact listing must fit the protocol frame"
718+
)
719+
}
720+
656721
static func diagnosticServices() throws {
657722
let store = QADiagnosticStore()
658723
store.append(kind: "console", level: "warn", message: "first")
@@ -785,6 +850,8 @@ struct ProtocolTests {
785850
("screenshot series helpers", screenshotSeriesHelpers),
786851
("diagnostic summary", diagnosticSummary),
787852
("diagnostic bounds and URL redaction", diagnosticsBoundAndRedacted),
853+
("responses fit the protocol frame", responsesFitTheProtocolFrame),
854+
("artifact listing stays bounded", artifactListingStaysBounded),
788855
("diagnostic services", diagnosticServices),
789856
("diagnostic CLI", diagnosticCLI),
790857
("local socket round-trip", localSocketRoundTrip),

apps/headless/docs/P1.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,14 @@ returned as data, but must not be interpreted as agent commands.
139139
Reports keep the newest 500 bounded events. Page output is data; it cannot add
140140
commands or execute shell code.
141141

142+
Responses are bounded so they always fit the 1 MiB protocol frame. `qa report`
143+
keeps the newest issues and events that fit the response budget, and
144+
`artifacts list` returns the newest entries. Both report `truncated` and an
145+
`omitted` count, and `qa report` keeps its `summary` counts describing every
146+
collected event rather than only the returned ones. A response that still
147+
cannot be framed fails with `RESPONSE_TOO_LARGE` rather than reporting an
148+
invalid request.
149+
142150
## Remote files and media
143151

144152
`inspect` reports decoded image/video dimensions, media readiness, source, and

docs/roadmap/improvements-backlog.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,16 @@ requirement so the guards can never become the only thing holding the path up.
5353
**A3. Oversized responses break the 1 MiB frame.** ([#14](https://github.com/LockInTime/headless/issues/14)) `qa report` can hold 500
5454
events × ~4 KiB ≈ 2 MB; `artifact.list` is unbounded. `encodeLine` throws
5555
inside `handleClient` and the client receives a misleading
56-
`INVALID_REQUEST` (`HP/Transport.swift:219-227`). Fix: response-side bounding —
56+
`INVALID_REQUEST` (`HP/Transport.swift:219-227`). ~~Fix: response-side bounding —
5757
pagination (`--limit/--cursor`) or truncation with `truncated: true` — per
5858
architecture decision §4. Test: generate >1 MiB of events, assert a bounded,
59-
well-formed response.
59+
well-formed response.~~ **Done** by truncation: `qa report` bounds its issue and
60+
event arrays by byte budget while keeping `summary` counts accurate over the
61+
whole buffer, `artifacts list` returns the newest 250 with `total`/`omitted`,
62+
and both report `truncated`. A response that still cannot be framed now fails
63+
`RESPONSE_TOO_LARGE` instead of `INVALID_REQUEST`. Tests assert both responses
64+
encode within `headlessMaximumMessageBytes`. Cursor pagination remains the
65+
richer answer and is tracked separately (§G3).
6066

6167
**A4. Accept-loop error spin.** ([#15](https://github.com/LockInTime/headless/issues/15)) All `accept()` errors are swallowed with
6268
`continue` (`HP/Transport.swift:180-184`); persistent EMFILE becomes a hot

0 commit comments

Comments
 (0)