Skip to content

Commit 55c3194

Browse files
authored
Merge pull request #205 from LockInTime/feat/204-network-idle-wait
feat(wait): add bounded Chromium network-idle predicate
2 parents 80d89e6 + 27a9c30 commit 55c3194

22 files changed

Lines changed: 349 additions & 22 deletions

File tree

‎.agents/skills/headless-computer-use/SKILL.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ controls the desktop, native applications, OS chrome, microphone, or system audi
2121
selection, Docker access, visible-browser mode, or MCP configuration matters.
2222

2323
Run `headless capabilities` once before relying on optional features. Network
24-
emulation, request mocking, and file upload are Linux Chromium capabilities;
25-
macOS WebKit reports them as unsupported.
24+
idle waits, network emulation, request mocking, and file upload are Linux
25+
Chromium capabilities; macOS WebKit reports them as unsupported.
2626

2727
## Follow the mandatory interaction loop
2828

@@ -33,7 +33,8 @@ macOS WebKit reports them as unsupported.
3333
scoped text, or scoped actions only when the task needs them.
3434
5. Prefer role/name targeting; otherwise use a ref from the latest inspection.
3535
6. After navigation or a substantial rerender, wait for the expected URL, text,
36-
or settled state and inspect again before the next interaction.
36+
settled state, or Chromium network idle and inspect again before the next
37+
interaction.
3738
7. Capture evidence and diagnostics in proportion to the task.
3839
8. Close the session. Stop the host only when this skill started it and no other
3940
task is using it.

‎.agents/skills/headless-computer-use/references/commands.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ headless --session NAME press KEY
3636
headless --session NAME scroll up|down|top|bottom --amount PIXELS
3737
headless --session NAME back
3838
headless --session NAME reload
39-
headless --session NAME wait --settled --url PATTERN --text TEXT --timeout MS
39+
headless --session NAME wait --settled --network-idle --url PATTERN --text TEXT --timeout MS
4040
headless --session NAME tour --full-page --pace PIXELS_PER_SECOND
4141
```
4242

@@ -59,7 +59,12 @@ Use `wait` with the strongest expected condition available:
5959
1. expected URL plus expected text;
6060
2. expected text;
6161
3. settled state;
62-
4. a bounded timeout only when no semantic condition exists.
62+
4. Chromium network idle when asynchronous requests must finish;
63+
5. a bounded timeout only when no semantic condition exists.
64+
65+
Network idle requires zero qualifying Chromium requests for 500 ms. Persistent
66+
WebSocket and EventSource connections are excluded, but ordinary long polling
67+
can time out. Check capabilities first because WebKit rejects this predicate.
6368

6469
Never replace a semantic wait with a long blind sleep.
6570

‎apps/headless/Host/AgentBridge.swift‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@ extension BrowserWindowController {
129129
}
130130

131131
func agentWait(parameters: [String: JSONValue]) throws -> JSONValue {
132+
if parameters["networkIdle"]?.boolValue == true {
133+
throw HostError(
134+
code: .unsupportedCapability,
135+
message: "Network-idle wait requires the Chromium CDP engine."
136+
)
137+
}
132138
let timeoutMs = min(120_000, max(100, parameters["timeoutMs"]?.numberValue ?? 20_000))
133139
let expectedURL = parameters["url"]?.stringValue
134140
let expectedText = parameters["text"]?.stringValue

‎apps/headless/LinuxHost/BrowserProcess.swift‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,7 @@ final class LinuxBrowserSession: @unchecked Sendable {
610610
let diagnostics = QADiagnosticStore()
611611
private let diagnosticsLock = NSLock()
612612
private var requestContexts: [String: (method: String?, url: String?, headers: [String: String])] = [:]
613+
private let networkIdleTracker = NetworkIdleTracker()
613614
private let recordingLock = NSLock()
614615
private var recordingPausedUntil = Date.distantPast
615616
private let navigationLock = NSLock()
@@ -827,6 +828,8 @@ final class LinuxBrowserSession: @unchecked Sendable {
827828
let expectedURL = parameters["url"]?.stringValue
828829
let expectedText = parameters["text"]?.stringValue
829830
let requireSettled = parameters["settled"]?.boolValue ?? false
831+
let requireNetworkIdle = parameters["networkIdle"]?.boolValue ?? false
832+
let networkWaitStartedAt = networkIdleTracker.beginWait()
830833
let deadline = Date().addingTimeInterval(timeoutMs / 1_000)
831834
var state: JSONValue = .object([:])
832835
repeat {
@@ -846,7 +849,10 @@ final class LinuxBrowserSession: @unchecked Sendable {
846849
let ready = object["readyState"]?.stringValue == "complete"
847850
let animations = object["runningAnimations"]?.numberValue ?? 0
848851
let quiet = object["mutationQuietMs"]?.numberValue ?? 0
849-
if urlMatches && textMatches && (!requireSettled || (ready && animations == 0 && quiet >= 300)) { return state }
852+
let settled = !requireSettled || (ready && animations == 0 && quiet >= 300)
853+
let networkIdle = !requireNetworkIdle
854+
|| networkIdleTracker.snapshot(since: networkWaitStartedAt).isIdle
855+
if urlMatches && textMatches && settled && networkIdle { return state }
850856
Thread.sleep(forTimeInterval: 0.05)
851857
} while Date() < deadline
852858
throw CDPError.timedOut
@@ -1190,6 +1196,10 @@ final class LinuxBrowserSession: @unchecked Sendable {
11901196
case "Network.requestWillBeSent":
11911197
guard let requestID = parameters["requestId"] as? String,
11921198
let request = parameters["request"] as? [String: Any] else { return }
1199+
networkIdleTracker.requestDidStart(
1200+
identifier: requestID,
1201+
resourceType: parameters["type"] as? String
1202+
)
11931203
diagnosticsLock.lock()
11941204
requestContexts[requestID] = (
11951205
request["method"] as? String,
@@ -1207,12 +1217,14 @@ final class LinuxBrowserSession: @unchecked Sendable {
12071217
responseHeaders: stringHeaders(response["headers"] as? [String: Any]), source: "chromium-cdp")
12081218
case "Network.loadingFailed":
12091219
let requestID = parameters["requestId"] as? String ?? ""
1220+
networkIdleTracker.requestDidFinish(identifier: requestID)
12101221
diagnosticsLock.lock(); let request = requestContexts.removeValue(forKey: requestID); diagnosticsLock.unlock()
12111222
diagnostics.append(kind: "request-failed", message: parameters["errorText"] as? String,
12121223
url: request?.url, method: request?.method, requestID: requestID,
12131224
requestHeaders: request?.headers, source: "chromium-cdp")
12141225
case "Network.loadingFinished":
12151226
if let requestID = parameters["requestId"] as? String {
1227+
networkIdleTracker.requestDidFinish(identifier: requestID)
12161228
diagnosticsLock.lock(); requestContexts.removeValue(forKey: requestID); diagnosticsLock.unlock()
12171229
}
12181230
default: break

‎apps/headless/Sources/HeadlessProtocol/CLI.swift‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -447,8 +447,12 @@ public struct CLIParser {
447447
let text = try removeOption("--text", from: &args)
448448
let timeoutText = try removeOption("--timeout", from: &args)
449449
let settled = removeFlag("--settled", from: &args)
450+
let networkIdle = removeFlag("--network-idle", from: &args)
450451
try requireEmpty(args)
451-
var parameters: [String: JSONValue] = ["settled": .bool(settled || (url == nil && text == nil))]
452+
var parameters: [String: JSONValue] = [
453+
"settled": .bool(settled || (url == nil && text == nil && !networkIdle)),
454+
]
455+
if networkIdle { parameters["networkIdle"] = .bool(true) }
452456
if let url { parameters["url"] = .string(url) }
453457
if let text { parameters["text"] = .string(text) }
454458
if let timeoutText {
@@ -855,7 +859,7 @@ Commands:
855859
upload REF --artifact FILE | upload --role ROLE [--name NAME] --artifact FILE
856860
scroll [up|down|top|bottom] [--amount PX]
857861
back | reload
858-
wait [--settled] [--url PATTERN] [--text TEXT] [--timeout MS]
862+
wait [--settled] [--network-idle] [--url PATTERN] [--text TEXT] [--timeout MS]
859863
tour [--full-page] [--pace PX_PER_SECOND]
860864
capture-info
861865
screenshot [REF | --role ROLE --name NAME | --full-page] [--format png|jpg|jpeg] [--output FILE] [--clipboard]

‎apps/headless/Sources/HeadlessProtocol/Capabilities.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public struct BrowserEngineCapabilities: Sendable {
2424
public let qaDiagnosticSynchronization: String
2525
public let screenshotClipboard: Bool
2626
public let inputDispatch: String
27+
public let networkIdleWait: Bool
2728
public let normalProfileStorage: String
2829
public let fileUpload: Bool
2930

@@ -68,6 +69,7 @@ public struct BrowserEngineCapabilities: Sendable {
6869
"screenshotClipboard": .bool(screenshotClipboard),
6970
"tourTimeoutMs": .number(65_000),
7071
"inputDispatch": .string(inputDispatch),
72+
"networkIdleWait": .bool(networkIdleWait),
7173
"fileUpload": .bool(fileUpload),
7274
"normalProfile": .object([
7375
"persistent": .bool(true),
@@ -113,6 +115,7 @@ public struct BrowserEngineCapabilities: Sendable {
113115
qaDiagnosticSynchronization: "best-effort-page-world-observer",
114116
screenshotClipboard: true,
115117
inputDispatch: "synthetic-dom",
118+
networkIdleWait: false,
116119
normalProfileStorage: "persistent-wkwebsite-data-store",
117120
fileUpload: false
118121
)
@@ -136,6 +139,7 @@ public struct BrowserEngineCapabilities: Sendable {
136139
qaDiagnosticSynchronization: "runtime-round-trip-flush",
137140
screenshotClipboard: false,
138141
inputDispatch: "trusted-cdp",
142+
networkIdleWait: true,
139143
normalProfileStorage: "private-xdg-data-directory",
140144
fileUpload: true
141145
)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import Foundation
2+
3+
public struct NetworkIdleSnapshot: Equatable, Sendable {
4+
public let activeRequestCount: Int
5+
public let quietMilliseconds: Int
6+
public let overflowed: Bool
7+
8+
public var isIdle: Bool {
9+
!overflowed && activeRequestCount == 0
10+
&& quietMilliseconds >= NetworkIdleTracker.quietPeriodMilliseconds
11+
}
12+
}
13+
14+
/// Tracks only browser-owned request identifiers. Request metadata and page
15+
/// URLs never leave the engine adapter or enter the wait response.
16+
public final class NetworkIdleTracker: @unchecked Sendable {
17+
public static let quietPeriodMilliseconds = 500
18+
public static let maximumTrackedRequests = 4_096
19+
20+
private static let persistentResourceTypes: Set<String> = ["eventsource", "websocket"]
21+
22+
private let lock = NSLock()
23+
private let monotonicNow: @Sendable () -> TimeInterval
24+
private var activeRequestIDs: Set<String> = []
25+
private var lastActivity: TimeInterval
26+
private var overflowed = false
27+
28+
public init(
29+
monotonicNow: @escaping @Sendable () -> TimeInterval = {
30+
ProcessInfo.processInfo.systemUptime
31+
}
32+
) {
33+
self.monotonicNow = monotonicNow
34+
self.lastActivity = monotonicNow()
35+
}
36+
37+
public func beginWait() -> TimeInterval {
38+
monotonicNow()
39+
}
40+
41+
public func requestDidStart(identifier: String, resourceType: String?) {
42+
lock.lock()
43+
defer { lock.unlock() }
44+
45+
let now = monotonicNow()
46+
let persistent = resourceType.map {
47+
Self.persistentResourceTypes.contains($0.lowercased())
48+
} ?? false
49+
if persistent {
50+
if activeRequestIDs.remove(identifier) != nil { lastActivity = now }
51+
return
52+
}
53+
54+
lastActivity = now
55+
guard identifier.utf8.count <= 512 else {
56+
overflowed = true
57+
return
58+
}
59+
if activeRequestIDs.contains(identifier) { return }
60+
guard activeRequestIDs.count < Self.maximumTrackedRequests else {
61+
overflowed = true
62+
return
63+
}
64+
activeRequestIDs.insert(identifier)
65+
}
66+
67+
public func requestDidFinish(identifier: String) {
68+
lock.lock()
69+
defer { lock.unlock() }
70+
if activeRequestIDs.remove(identifier) != nil {
71+
lastActivity = monotonicNow()
72+
}
73+
}
74+
75+
public func snapshot(since waitStartedAt: TimeInterval) -> NetworkIdleSnapshot {
76+
lock.lock()
77+
defer { lock.unlock() }
78+
let quietStart = max(waitStartedAt, lastActivity)
79+
let quietMilliseconds = max(0, Int((monotonicNow() - quietStart) * 1_000))
80+
return NetworkIdleSnapshot(
81+
activeRequestCount: activeRequestIDs.count,
82+
quietMilliseconds: quietMilliseconds,
83+
overflowed: overflowed
84+
)
85+
}
86+
}

‎apps/headless/Sources/HeadlessProtocol/Protocol.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ public struct CommandRequest: Codable, Equatable, Sendable {
283283
)
284284
case .wait:
285285
try boolean("settled")
286+
try boolean("networkIdle")
286287
_ = try string("url")
287288
_ = try string("text", maximumBytes: 30_000)
288289
_ = try number("timeoutMs", minimum: 100, maximum: 120_000)

‎apps/headless/Sources/HeadlessProtocol/ProtocolSchema.swift‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -721,7 +721,8 @@ public let protocolCommandDefinitions: [CommandName: ProtocolCommandDefinition]
721721
command(.back, untrusted: true),
722722
command(.reload, untrusted: true),
723723
command(.wait, [
724-
boolean("settled"), string("url"), string("text", maximumBytes: 30_000),
724+
boolean("settled"), boolean("networkIdle"), string("url"),
725+
string("text", maximumBytes: 30_000),
725726
number("timeoutMs", minimum: 100, maximum: 120_000),
726727
], untrusted: true),
727728
command(.tour, [boolean("fullPage"), number("pace", minimum: 100, maximum: 5_000)], untrusted: true),
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<title>Network idle fixture</title>
6+
</head>
7+
<body>
8+
<main>
9+
<h1>Network idle fixture</h1>
10+
<button type="button">Start network request</button>
11+
<output aria-label="Network request state">not started</output>
12+
</main>
13+
<script>
14+
const button = document.querySelector('button');
15+
const output = document.querySelector('output');
16+
button.addEventListener('click', async () => {
17+
output.textContent = 'request pending';
18+
try {
19+
await fetch('/api/diagnostic?network-idle=1', {cache: 'no-store'});
20+
output.textContent = 'request complete';
21+
} catch (_) {
22+
output.textContent = 'request failed';
23+
}
24+
});
25+
</script>
26+
</body>
27+
</html>

0 commit comments

Comments
 (0)