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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ Cutting that release is tracked in

### Fixed

- Linux DevTools-pipe framing now tracks its scan cursor and amortizes buffer
compaction, avoiding quadratic work for large screenshot responses.
- Phase 1 hardening now bounds Chromium teardown after `SIGKILL`, uses libc's
peer-credential constant, deterministically caps diagnostic headers, drops
malformed CDP header values, atomically finalizes artifacts without
Expand Down
12 changes: 7 additions & 5 deletions apps/headless/LinuxHost/CDP.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import HeadlessProtocol
import Foundation
#if canImport(Darwin)
import Darwin
Expand Down Expand Up @@ -181,7 +182,7 @@ private final class RawDevToolsPipe: CDPTransport, @unchecked Sendable {
private let inputDescriptor: Int32
private let outputDescriptor: Int32
private let stateLock = NSLock()
private var pending: [UInt8] = []
private var pending = NullTerminatedMessageBuffer()
private var closed = false
// Page.captureScreenshot returns base64 in a CDP response. This is an
// internal browser pipe (not the 1 MiB agent socket), so it needs room for
Expand Down Expand Up @@ -228,15 +229,13 @@ private final class RawDevToolsPipe: CDPTransport, @unchecked Sendable {

func receiveText(timeoutMilliseconds: Int32) throws -> String {
while true {
if let terminator = pending.firstIndex(of: 0) {
let message = Array(pending[..<terminator])
pending.removeFirst(terminator + 1)
if let message = pending.popFirst() {
guard let text = String(bytes: message, encoding: .utf8) else {
throw CDPError.invalidResponse("non-UTF8 DevTools pipe message")
}
return text
}
guard pending.count <= maximumMessageBytes else {
guard pending.bufferedByteCount <= maximumMessageBytes else {
throw CDPError.invalidResponse("DevTools pipe message too large")
}
try waitUntilReady(
Expand All @@ -251,6 +250,9 @@ private final class RawDevToolsPipe: CDPTransport, @unchecked Sendable {
throw CDPError.invalidResponse("DevTools pipe read: \(lastSystemError())")
}
guard count > 0 else { throw CDPError.invalidResponse("DevTools pipe closed") }
guard pending.bufferedByteCount <= maximumMessageBytes - count else {
throw CDPError.invalidResponse("DevTools pipe message too large")
}
pending.append(contentsOf: buffer[..<count])
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/// Incrementally frames NUL-terminated byte messages without rescanning bytes
/// that were already inspected. Consumed prefixes are compacted only after a
/// complete message, keeping large chunked payloads linear in their size.
package struct NullTerminatedMessageBuffer {
private var storage: [UInt8] = []
private var messageStart = 0
private var scanOffset = 0

package init() {}

package var bufferedByteCount: Int { storage.count - messageStart }
package var unscannedByteCount: Int { storage.count - scanOffset }

package mutating func append(contentsOf bytes: ArraySlice<UInt8>) {
storage.append(contentsOf: bytes)
}

package mutating func popFirst() -> [UInt8]? {
guard let terminator = storage[scanOffset...].firstIndex(of: 0) else {
scanOffset = storage.endIndex
return nil
}
let message = Array(storage[messageStart..<terminator])
messageStart = terminator + 1
scanOffset = messageStart
compactConsumedPrefix()
return message
}

private mutating func compactConsumedPrefix() {
if messageStart == storage.count {
storage.removeAll(keepingCapacity: true)
messageStart = 0
scanOffset = 0
} else if messageStart >= 65_536, messageStart >= storage.count / 2 {
let removed = messageStart
storage.removeFirst(removed)
messageStart = 0
scanOffset -= removed
}
}
}
29 changes: 29 additions & 0 deletions apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1511,6 +1511,34 @@ struct ProtocolTests {
_ = requestFinished.wait(timeout: .now() + 2)
}

static func nullTerminatedBufferScansIncrementally() throws {
var buffer = NullTerminatedMessageBuffer()
let chunk = [UInt8](repeating: 0x61, count: 8_192)
let chunkSlice = chunk[...]
let chunkCount = 30 * 1_024 * 1_024 / chunk.count

for _ in 0..<chunkCount {
buffer.append(contentsOf: chunkSlice)
try expect(
buffer.unscannedByteCount == chunk.count,
"only newly appended CDP bytes should remain unscanned"
)
try expect(buffer.popFirst() == nil, "unterminated CDP payload should remain buffered")
try expect(buffer.unscannedByteCount == 0, "the CDP scan cursor should advance to the buffer end")
}
try expect(
buffer.bufferedByteCount == 30 * 1_024 * 1_024,
"large chunked CDP payload should retain every byte"
)
buffer.append(contentsOf: [UInt8(0)][...])
try expect(buffer.popFirst()?.count == 30 * 1_024 * 1_024, "terminator should release the complete CDP payload")
try expect(buffer.bufferedByteCount == 0, "consumed CDP storage should compact")

buffer.append(contentsOf: Array("one\0two\0".utf8)[...])
try expect(buffer.popFirst() == Array("one".utf8), "first buffered CDP message changed")
try expect(buffer.popFirst() == Array("two".utf8), "second buffered CDP message changed")
}

static func main() {
if CommandLine.arguments.count == 3,
CommandLine.arguments[1] == "--peer-denied-client" {
Expand Down Expand Up @@ -1571,6 +1599,7 @@ struct ProtocolTests {
("shutdown bypasses busy request", shutdownBypassesBusyRequest),
("oversized socket request", oversizedSocketRequestIsRejected),
("different peer uid", differentPeerUserIsRejected),
("incremental NUL message buffering", nullTerminatedBufferScansIncrementally),
]

var failures = 0
Expand Down
6 changes: 4 additions & 2 deletions docs/roadmap/improvements-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,12 @@ id. `CommandResponse.unknownRequestIdentifier` is the one documented
exception, for replies where the host could not read the request at all —
those still have to reach the caller with their reason.

**A8. CDP O(n²) buffering.** ([#19](https://github.com/LockInTime/headless/issues/19)) `receiveText` rescans the whole buffer and
**A8. CDP O(n²) buffering.** ([#19](https://github.com/LockInTime/headless/issues/19)) ~~`receiveText` rescans the whole buffer and
`removeFirst`s per 8 KiB read (`LinuxHost/CDP.swift:229-256`); a 30 MB
base64 screenshot triggers thousands of full scans under a 128 MiB cap.
Track a scan offset / use a ring buffer.
Track a scan offset / use a ring buffer.~~ **Done:** a shared incremental NUL
message buffer scans each appended region once and amortizes prefix compaction;
protocol coverage feeds it a 30 MiB message in the host's 8 KiB read chunks.

**A9. Misc hardening (smaller, same phase).** ([#20](https://github.com/LockInTime/headless/issues/20))
- ~~`ChromiumChildProcess.stop()` can busy-wait forever post-SIGKILL
Expand Down
Loading