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
4 changes: 1 addition & 3 deletions apps/headless/MCP/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,8 @@ while let line = readLine() {
toolResult(id: id, text: "MCP accepts browser commands only; run `headless start` on the VM first.", isError: true)
continue
}
let isLongScreenshot = command.command == .screenshot
&& command.parameters["series"]?.stringValue != nil
let response = try LocalSocketClient().send(
command, timeout: command.command == .tour || isLongScreenshot ? 125 : 30
command, timeout: requestTimeout(for: command)
)
let encoded = try ProtocolCodec.encoder.encode(response)
toolResult(id: id, text: String(decoding: encoded, as: UTF8.self), isError: !response.ok)
Expand Down
16 changes: 2 additions & 14 deletions apps/headless/Sources/HeadlessCLI/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,18 +101,6 @@ private enum HostLaunchError: Error, CustomStringConvertible {
}
}

private func requestTimeout(_ request: CommandRequest) -> TimeInterval {
if let milliseconds = request.parameters["timeoutMs"]?.numberValue {
return min(125, max(10, milliseconds / 1_000 + 5))
}
if request.command == .tour { return 125 }
if request.command == .recordStop { return 30 }
if request.command == .screenshot {
return request.parameters["series"]?.stringValue == nil ? 30 : 125
}
return 15
}

do {
let invocation = try CLIParser().parse(Array(CommandLine.arguments.dropFirst()))
if let local = invocation.local {
Expand All @@ -137,10 +125,10 @@ do {
let launcher = HostLauncher()
let response: CommandResponse
do {
response = try launcher.client.send(request, timeout: requestTimeout(request))
response = try launcher.client.send(request, timeout: requestTimeout(for: request))
} catch LocalTransportError.connectionFailed where request.command != .ping && request.command != .shutdown {
_ = try launcher.start()
response = try launcher.client.send(request, timeout: requestTimeout(request))
response = try launcher.client.send(request, timeout: requestTimeout(for: request))
}
try printResponse(response)
if !response.ok { exit(1) }
Expand Down
12 changes: 12 additions & 0 deletions apps/headless/Sources/HeadlessProtocol/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ public struct CLIInvocation: Equatable, Sendable {
}
}

public func requestTimeout(for request: CommandRequest) -> TimeInterval {
if let milliseconds = request.parameters["timeoutMs"]?.numberValue {
return min(125, max(10, milliseconds / 1_000 + 5))
}
if request.command == .tour { return 125 }
if request.command == .recordStop { return 30 }
if request.command == .screenshot {
return request.parameters["series"]?.stringValue == nil ? 30 : 125
}
return 15
}

public enum CLIParseError: Error, Equatable, CustomStringConvertible {
case missingCommand
case unknownCommand(String)
Expand Down
22 changes: 22 additions & 0 deletions apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,27 @@ struct ProtocolTests {
}
}

static func clientTimeoutsMatchCommandBounds() throws {
let longWait = try CLIParser().parse(["wait", "--timeout", "90000"])
try expect(
longWait.request.map { requestTimeout(for: $0) } == 95,
"wait timeout should include the five-second transport allowance"
)
let maximumWait = try CLIParser().parse(["wait", "--timeout", "120000"])
try expect(maximumWait.request.map { requestTimeout(for: $0) } == 125, "wait timeout should remain capped")
let shortWait = try CLIParser().parse(["wait", "--timeout", "100"])
try expect(shortWait.request.map { requestTimeout(for: $0) } == 10, "wait timeout should retain the transport minimum")

try expect(requestTimeout(for: CommandRequest(command: .tour)) == 125, "tour should use the long timeout")
try expect(
requestTimeout(for: CommandRequest(command: .screenshot, parameters: ["series": .string("viewport")])) == 125,
"screenshot series should use the long timeout"
)
try expect(requestTimeout(for: CommandRequest(command: .screenshot)) == 30, "single screenshots should get 30 seconds")
try expect(requestTimeout(for: CommandRequest(command: .recordStop)) == 30, "record stop should get 30 seconds")
try expect(requestTimeout(for: CommandRequest(command: .ping)) == 15, "ordinary commands should use the shared default")
}

static func cliP1Artifacts() throws {
let screenshot = try CLIParser().parse([
"--session", "qa", "screenshot", "--role", "button", "--name", "Continue",
Expand Down Expand Up @@ -876,6 +897,7 @@ struct ProtocolTests {
("CLI conflicting target", cliRejectsConflictingClickTarget),
("CLI settled wait", cliWaitDefaultsToSettled),
("CLI timeout bound", cliRejectsUnboundedTimeout),
("client timeout parity", clientTimeoutsMatchCommandBounds),
("CLI P1 artifacts", cliP1Artifacts),
("CLI P2 commands and boundaries", cliP2CommandsAndBoundaries),
("Chromium runtime selection", chromiumRuntimeSelection),
Expand Down
4 changes: 3 additions & 1 deletion docs/roadmap/improvements-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,9 @@ architecture decision §7.
**C1. Timeout parity.** ([#29](https://github.com/LockInTime/headless/issues/29)) MCP uses flat 30 s except tour/series
(`apps/headless/MCP/main.swift:70-72`); CLI derives from `--timeout`
(`HeadlessCLI/main.swift:104-114`). `wait --timeout 90000` works in CLI, dies
via MCP. Derive identically.
via MCP. ~~Derive identically.~~ **Done:** both adapters now call the same
`HeadlessProtocol.requestTimeout(for:)` helper. Coverage locks the ordinary,
wait-derived, tour, screenshot-series, screenshot, and recording-stop cases.

**C2. Destructive verbs over MCP.** ([#30](https://github.com/LockInTime/headless/issues/30)) `stop` (shutdown) and `session close` are
callable though the tool description says "safe"; decide policy (deny, or
Expand Down
Loading