diff --git a/src/claude/intercept/picker-listener.ts b/src/claude/intercept/picker-listener.ts index d115c7faad1..39b3f6d9b4c 100644 --- a/src/claude/intercept/picker-listener.ts +++ b/src/claude/intercept/picker-listener.ts @@ -34,6 +34,12 @@ export interface PickerListenerOptions { maxEncodedBytes?: number; /** Test seam: concurrent upstream requests; production uses PICKER_MAX_ACTIVE_UPSTREAMS. */ maxActiveUpstreams?: number; + /** Test seam: maximum time for a client upload to finish. */ + uploadDeadlineMs?: number; + /** Test seam: maximum time without upload progress. */ + uploadIdleTimeoutMs?: number; + /** Test seam: observe or hold the relay's upstream writable before piping input. */ + onUpstreamRequest?: (request: ReturnType) => void; /** * Test seam: arms the drop of a connection whose backend ended, after PICKER_ABANDONED_INPUT_LINGER_MS; * returns a cancel function. Production uses a timer. @@ -56,6 +62,9 @@ export const PICKER_MAX_ACTIVE_UPSTREAMS = 256; export const PICKER_MAX_CONCURRENT_STREAMS = 100; /** A connection that has not sent a complete ClientHello by then is dropped. */ const CLIENT_HELLO_TIMEOUT_MS = 10_000; +const PICKER_UPLOAD_DEADLINE_MS = 60_000; +const PICKER_UPLOAD_IDLE_TIMEOUT_MS = 15_000; +const MAX_TIMER_DELAY_MS = 2_147_483_647; /** * Once the backend has ended a connection and its reply has been flushed, input the client still * sends is discarded for at most this long before the connection is dropped. A cooperative client @@ -163,6 +172,13 @@ export async function startPickerListener(options: PickerListenerOptions): Promi const upstream = options.upstream ?? { host: "claude.ai", port: 443, servername: "claude.ai" }; const cap = options.maxEncodedBytes ?? BOOTSTRAP_MAX_ENCODED_BYTES; const maxActiveUpstreams = options.maxActiveUpstreams ?? PICKER_MAX_ACTIVE_UPSTREAMS; + const uploadDeadlineMs = options.uploadDeadlineMs ?? PICKER_UPLOAD_DEADLINE_MS; + const uploadIdleTimeoutMs = options.uploadIdleTimeoutMs ?? PICKER_UPLOAD_IDLE_TIMEOUT_MS; + for (const [name, value] of [["uploadDeadlineMs", uploadDeadlineMs], ["uploadIdleTimeoutMs", uploadIdleTimeoutMs]] as const) { + if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) { + throw new RangeError(`${name} must be a positive finite timer duration`); + } + } const scheduleAbandonedInputDrop = options.scheduleAbandonedInputDrop ?? ((drop: () => void, ms: number) => { const timer = setTimeout(drop, ms); return () => clearTimeout(timer); @@ -235,7 +251,64 @@ export async function startPickerListener(options: PickerListenerOptions): Promi // Do not leave a refused, still-uploading stream behind after delivering its empty reply. if (uploading && !req.complete) { req.once("error", () => res.destroy()); - responseWritable.once("finish", () => closeInput(true)); + if (h2) { + responseWritable.once("finish", () => closeInput(true)); + } else { + let settled = false; + let replyFinished = false; + let inputFinished = false; + let deadlineTimer: NodeJS.Timeout | undefined; + let idleTimer: NodeJS.Timeout | undefined; + const cleanup = () => { + if (settled) return; + settled = true; + if (deadlineTimer) clearTimeout(deadlineTimer); + if (idleTimer) clearTimeout(idleTimer); + req.off("data", onProgress); + req.off("end", onBodyEnd); + req.off("close", onBodyGone); + req.off("error", onBodyGone); + responseWritable.off("finish", onReplyFinished); + }; + // The overridden destroySoon already sent a graceful FIN and resumed input, so a + // timeout must destroy: another end() on that socket would be a no-op and a client + // that keeps uploading could hold the half-closed socket until shutdown. + const closeOnTimeout = () => { + cleanup(); + req.destroy(); + }; + const onProgress = () => { + if (settled || !deadlineTimer) return; + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(closeOnTimeout, uploadIdleTimeoutMs); + idleTimer.unref?.(); + }; + // A body that finishes cleanly leaves a keep-alive socket the client may + // reuse; only a deadline or a premature close ends it. + const onBodyEnd = () => { + inputFinished = true; + if (replyFinished) cleanup(); + }; + const onBodyGone = () => { + if (inputFinished) return; + inputFinished = true; + if (replyFinished) { cleanup(); closeInput(true); } + }; + const onReplyFinished = () => { + if (settled) return; + replyFinished = true; + if (inputFinished || req.complete) { cleanup(); return; } + deadlineTimer = setTimeout(closeOnTimeout, uploadDeadlineMs); + deadlineTimer.unref?.(); + onProgress(); + }; + req.on("data", onProgress); + req.once("end", onBodyEnd); + req.once("close", onBodyGone); + req.once("error", onBodyGone); + responseWritable.once("finish", onReplyFinished); + req.resume(); + } } res.writeHead(status, { "Content-Length": "0", ...(!h2 && uploading && !req.complete ? { Connection: "close" } : {}) }); res.end(); @@ -271,6 +344,9 @@ export async function startPickerListener(options: PickerListenerOptions): Promi if (bootstrap) headers.push("Accept-Encoding", narrowBootstrapAcceptEncoding()); let upReq: ReturnType; let upstreamResponded = false; + // Upload clocks only bound the pre-response wait: once upstream answers, the + // reply must be allowed to finish even if the client's input never does. + let stopUploadClocks: () => void = () => {}; try { // The HTTP/1.1 client validates the method, path and header values synchronously. upReq = httpsRequest({ @@ -279,6 +355,7 @@ export async function startPickerListener(options: PickerListenerOptions): Promi method, path: req.url, headers, maxHeaderSize: PICKER_MAX_HEADER_BYTES, }, upRes => { upstreamResponded = true; + stopUploadClocks(); const status = upRes.statusCode ?? 502; const originalHeaders = filteredHeaders(upRes.rawHeaders); const sendHead = (raw: string[]) => { @@ -358,9 +435,36 @@ export async function startPickerListener(options: PickerListenerOptions): Promi if (h2) (req as Http2ServerRequest).stream.once("close", onClientClose); if (uploading) { let uploadFinished = false; + let deadlineTimer: NodeJS.Timeout | undefined; + let idleTimer: NodeJS.Timeout | undefined; + let expireUpload: () => void = () => {}; + const clearUploadTimers = () => { + if (deadlineTimer) clearTimeout(deadlineTimer); + if (idleTimer) clearTimeout(idleTimer); + deadlineTimer = undefined; + idleTimer = undefined; + }; + stopUploadClocks = clearUploadTimers; + const onUploadIdle = () => { + idleTimer = undefined; + if (upstreamResponded) return; + // A full upstream buffer pauses the pipe: 'data' stops even while the client + // keeps sending, so backpressure is not client idleness. Recheck each interval + // until the upstream drains; the absolute deadline still bounds the upload. + if (upReq.writableNeedDrain) { onUploadProgress(); return; } + expireUpload(); + }; + const onUploadProgress = () => { + if (upstreamResponded) return; + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(onUploadIdle, uploadIdleTimeoutMs); + idleTimer.unref?.(); + }; const finishUpload = () => { if (uploadFinished) return; uploadFinished = true; + clearUploadTimers(); + req.off("data", onUploadProgress); req.off("end", finishUpload); req.off("close", finishUpload); upReq.off("close", onUpstreamClose); @@ -378,17 +482,28 @@ export async function startPickerListener(options: PickerListenerOptions): Promi upReq.destroy(); } }; + expireUpload = () => { + if (uploadFinished || upstreamResponded) return; + clientGone = true; + stopUnfinishedUpload(); + release(); + }; const onResponseFinish = () => stopUnfinishedUpload(true); const onUpstreamClose = () => { if (!upstreamResponded && !res.writableEnded) stopUnfinishedUpload(); }; req.once("end", finishUpload); req.once("close", finishUpload); + req.on("data", onUploadProgress); upReq.once("close", onUpstreamClose); responseWritable.once("finish", onResponseFinish); res.once("close", stopUnfinishedUpload); if (h2) (req as Http2ServerRequest).stream.once("close", finishUpload); + deadlineTimer = setTimeout(expireUpload, uploadDeadlineMs); + deadlineTimer.unref?.(); + onUploadProgress(); } + options.onUpstreamRequest?.(upReq); req.pipe(upReq); }; server.on("request", relay); diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 78893a3bb85..86df45e16aa 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -220,9 +220,9 @@ natively, counting name + value + 32 bytes per field and rejecting an oversized The relay retains its 256-request aggregate ceiling across both HTTP versions and all sessions. Upload framing, not the method, determines whether input cleanup is needed: HTTP/2 headers without END_STREAM, or HTTP/1.1 transfer encoding or a positive Content-Length. -Rejected unfinished uploads close after their empty reply's writable finishes; HTTP/2 uses the +Rejected unfinished uploads preserve the empty reply before cleanup; HTTP/2 uses the underlying stream's finish event rather than the compatibility response's finish event, which -waits for both stream halves to close. HTTP/1.1 refusals advertise `Connection: close` only while input is unfinished. +waits for both stream halves to close. Early replies stop relaying unfinished input only after the downstream writable finishes or the response closes; upstream request closure alone does not prove a buffered reply was delivered. After a complete response, HTTP/2 closes only that stream with `RST_STREAM NO_ERROR` (RFC 9113 @@ -231,12 +231,16 @@ After a complete response, HTTP/2 closes only that stream with `RST_STREAM NO_ER HTTP/1.1 refusals, generated 502s and early replies advertise `Connection: close` only while input is unfinished; after the writable finishes, the listener unpipes the upstream upload, reads and discards remaining input, and ends the socket gracefully so queued response bytes flush. Errors and premature downstream closes still destroy -the request. The request-side drain adds no upload deadline or byte cap and does not wait for request-body +the request. The request-side drain adds no byte cap and does not wait for request-body completion before ending the response side. Bun 1.4.3's HTTP server ends a `Connection: close` response with `socket.destroySoon()` before the listener's own finish handler runs, which reset the splice before the reply reached the client; every HTTP/1.1 reply that closes unfinished input (refusal, generated 502, early upstream reply) therefore replaces that socket's `destroySoon` with -the same resume-and-`end()`. The TCP front +the same resume-and-`end()`. A refused HTTP/1.1 socket whose client keeps uploading past that +graceful close is then destroyed outright at the upload deadline or after 15 s without progress. +On the relay path the 60-second absolute and 15-second idle-progress deadlines close client input +and upstream work and release the aggregate slot; upstream backpressure only +suspends the idle clock, never the absolute deadline. The TCP front enables half-open reads once a ClientHello is recognized and, when the backend side ends, unpipes and keeps discarding client ciphertext, so a client still writing an abandoned upload reads the reply and FIN and can close (Bun 1.4.2 otherwise left it open). That front-side discard is bounded: @@ -245,11 +249,12 @@ once the reply has flushed to the client socket (`finish`), the connection is dr hold it until shutdown (#6984). The timer is armed only after the reply has left the process, so it cannot cut a reply that is still being written; `finish` is not peer receipt, though, and a client that also stops reading for the whole budget can still lose unread reply bytes when the drop fires. -A connection that closes first cancels the timer. Body completion removes the input-cleanup listeners, +A connection that closes first cancels the timer. Body completion removes the input-cleanup listeners and both timers, leaving long-lived responses and SSE subscriptions independent of upload cleanup. Completed HTTP/1.1 uploads retain keep-alive on generated 502s and can reuse the same client socket. `tests/claude-integration/claude-picker-upload.test.ts` covers headers-only refusals on both protocols, byte-exact early replies under backpressure, completed uploads with SSE, cancellation, failure and shutdown cleanup, and healthy sibling streams. +The upload regression fixture holds and observes the relay's actual upstream writable before asserting idle and absolute-deadline behavior. The picker CA (`picker-ca.ts`) carries critical name constraints permitting only `claude.ai` and excluding every IPv4 and IPv6 address. Its exportable signing identity is protected by the OS credential store and scoped to the canonical config directory; diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 8d4cd0d1859..e2426aff14b 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -70,13 +70,15 @@ function h2Result(stream: ClientHttp2Stream) { } function h1Result(req: ClientRequest) { const result = Promise.withResolvers<{ status: number; text: string }>(); + let responseStarted = false; req.once("response", res => { + responseStarted = true; const chunks: Buffer[] = []; res.on("data", chunk => chunks.push(Buffer.from(chunk))); res.once("end", () => result.resolve({ status: res.statusCode!, text: Buffer.concat(chunks).toString() })); res.once("error", result.reject); }); - req.once("error", result.reject); + req.once("error", error => { if (!responseStarted) result.reject(error); }); void result.promise.catch(() => {}); return result.promise; } @@ -89,7 +91,7 @@ function observeClose(stream: ClientRequest | ClientHttp2Stream | ClientHttp2Ses function observeSocketClose(req: ClientRequest) { const closed = Promise.withResolvers(); req.once("socket", socket => { socket.once("close", () => closed.resolve()); }); - req.on("error", () => req.destroy()); + req.on("error", () => {}); return closed.promise; } function normalH2(session: ClientHttp2Session, path = "/ok") { @@ -109,7 +111,7 @@ for (const protocol of ["h1", "h2"] as const) { if (req.url === "/held") { req.once("close", () => left.resolve()); held.resolve(); } else { relayed++; res.end("ordinary"); } - }, { maxActiveUpstreams: 1 }); + }, { maxActiveUpstreams: 1, uploadIdleTimeoutMs: 250 }); try { const session = f.h2(); let blocker: ClientHttp2Stream | undefined; @@ -145,6 +147,29 @@ for (const protocol of ["h1", "h2"] as const) { } } +bounded("h1 unfinished refusal closes its advertised connection and the next request stays healthy", async wait => { + const body = "complete"; + const f = await fixture((req, res) => { + req.resume(); res.end("ordinary"); + }); + try { + // A refused upload that finishes its body must not have its socket closed: + // the keep-alive advertisement in the empty reply stays honest and the next + // request on that socket is relayed instead of dropped. + const upload = f.h1("//[", "POST", { "Content-Length": String(body.length) }); + const firstSocket = Promise.withResolvers(); + upload.once("socket", firstSocket.resolve); + const result = h1Result(upload); upload.end(body); + expect(await wait(result)).toEqual({ status: 400, text: "" }); + const next = f.h1("/next"); + const nextSocket = Promise.withResolvers(); + next.once("socket", nextSocket.resolve); + const nextResult = h1Result(next); next.end(); + expect(await wait(nextResult)).toEqual({ status: 200, text: "ordinary" }); + expect(await wait(nextSocket.promise)).not.toBe(await wait(firstSocket.promise)); + } finally { await wait(f.close()); } +}); + bounded("h1 completed upload keeps the same connection alive after an upstream failure", async wait => { const body = "complete"; let received = ""; @@ -254,6 +279,99 @@ for (const protocol of ["h1", "h2"] as const) { }); } +bounded("unfinished uploads share the aggregate budget across HTTP/2 sessions and recover on idle timeout", async wait => { + const arrived = Promise.withResolvers(); + let requests = 0; + const f = await fixture((req, res) => { + requests++; + if (req.url === "/stall") { + req.on("data", () => {}); + req.once("end", () => res.end("uploaded")); + arrived.resolve(); + } else res.end("ordinary"); + }, { maxActiveUpstreams: 1, uploadDeadlineMs: 1_000, uploadIdleTimeoutMs: 350 }); + try { + const firstSession = f.h2(); + const secondSession = f.h2(); + const upload = firstSession.request({ ":method": "POST", ":path": "/stall", ":authority": "claude.ai" }); + const uploadClosed = observeClose(upload); + upload.write("partial"); + await wait(arrived.promise); + const blocked = await wait(normalH2(secondSession, "/blocked")); + expect(blocked.status).toBe(503); + await wait(uploadClosed); + expect((await wait(normalH2(secondSession, "/ok"))).status).toBe(200); + expect(requests).toBe(2); + } finally { await wait(f.close()); } +}); + +bounded("absolute upload deadline expires despite continued progress", async wait => { + const arrived = Promise.withResolvers(); + const f = await fixture((req, res) => { + req.on("data", () => {}); + arrived.resolve(); + req.once("end", () => res.end("uploaded")); + }, { uploadDeadlineMs: 400, uploadIdleTimeoutMs: 250 }); + let progress: NodeJS.Timeout | undefined; + try { + const session = f.h2(); + const upload = session.request({ ":method": "POST", ":path": "/trickle", ":authority": "claude.ai" }); + const closed = observeClose(upload); + progress = setInterval(() => upload.write("x"), 80); + upload.write("start"); + await wait(arrived.promise); + await wait(closed); + expect(upload.rstCode).toBe(constants.NGHTTP2_CANCEL); + } finally { if (progress) clearInterval(progress); await wait(f.close()); } +}); + +bounded("upstream backpressure does not expire an active upload as idle", async wait => { + const upstreamRequest = Promise.withResolvers(); + const f = await fixture((_req, res) => res.end("ordinary"), { + uploadDeadlineMs: 1_000, uploadIdleTimeoutMs: 250, + onUpstreamRequest: request => { + // Hold the actual relay writable so the fixture, not socket buffer sizing, + // determines how long upstream backpressure persists. + request.cork(); + upstreamRequest.resolve(request); + }, + }); + try { + const session = f.h2(); + const upload = session.request({ ":method": "POST", ":path": "/stall", ":authority": "claude.ai" }); + const closed = observeClose(upload); + let closedEarly = false; + upload.once("close", () => { closedEarly = true; }); + upload.write(Buffer.alloc(4 * 1024 * 1024, 0x61)); + const relayRequest = await wait(upstreamRequest.promise); + await wait((async () => { + while (!relayRequest.writableNeedDrain) { + if (relayRequest.destroyed) throw new Error("relay closed before upstream backpressure"); + await new Promise(resolve => setTimeout(resolve, 5)); + } + })()); + await wait(new Promise(resolve => setTimeout(resolve, 600))); + expect(relayRequest.writableNeedDrain).toBe(true); + expect(closedEarly).toBe(false); + await wait(closed); + expect(upload.rstCode).toBe(constants.NGHTTP2_CANCEL); + } finally { await wait(f.close()); } +}); + +bounded("completed uploads are not timed out while a long response is pending", async wait => { + const f = await fixture((req, res) => { + req.resume(); + req.once("end", () => setTimeout(() => res.end("slow response"), 120)); + }, { uploadDeadlineMs: 50, uploadIdleTimeoutMs: 25 }); + try { + const session = f.h2(); + const upload = session.request({ ":method": "POST", ":path": "/complete", ":authority": "claude.ai" }); + const result = h2Result(upload); + upload.end("complete"); + expect(await wait(result)).toEqual({ status: 200, text: "slow response" }); + } finally { await wait(f.close()); } +}); + for (const protocol of ["h1", "h2"] as const) { bounded(`${protocol} backpressure preserves every byte of a large early reply`, async wait => { // Exceed the h2 receive window and the h1 socket buffers, including a non-text tail. @@ -378,3 +496,29 @@ for (const protocol of ["h1", "h2"] as const) { } finally { await wait(f.close()); } }); } + +for (const protocol of ["h1", "h2"] as const) { + bounded(`${protocol} first upstream response stops upload clocks so a slow reply completes`, async wait => { + // Upload deadline 400ms, idle 250ms: the reply lands at ~600ms, so either clock + // would abort the relay mid-response if it survived the upstream's first reply. + const f = await fixture((req, res) => { + req.on("error", () => res.destroy()); req.resume(); + const socket = req.socket; + socket.destroySoon = () => { socket.end(); return socket; }; + if (req.url === "/slow-reply") { + res.writeHead(200, { "Content-Length": "5" }); + res.write("s"); + setTimeout(() => res.end("low!"), 600); + } else res.end("ordinary"); + }, { uploadDeadlineMs: 400, uploadIdleTimeoutMs: 250 }); + try { + const session = f.h2(); + const upload = protocol === "h2" + ? session.request({ ":method": "POST", ":path": "/slow-reply", ":authority": "claude.ai" }) + : f.h1("/slow-reply", "POST", { "Content-Length": "100" }); + const result = protocol === "h2" ? h2Result(upload as ClientHttp2Stream) : h1Result(upload as ClientRequest); + upload.write("x"); + expect(await wait(result)).toEqual({ status: 200, text: "slow!" }); + } finally { await wait(f.close()); } + }); +} diff --git a/tests/helpers/windows-tray-probe-lifecycle-driver.ps1 b/tests/helpers/windows-tray-probe-lifecycle-driver.ps1 index 00ea31af25e..0101ad5190f 100644 --- a/tests/helpers/windows-tray-probe-lifecycle-driver.ps1 +++ b/tests/helpers/windows-tray-probe-lifecycle-driver.ps1 @@ -137,21 +137,14 @@ $script:pendingOldProxyPid = $null $script:pendingProcess = $null $childPid = 0 -if ($Scenario -eq "BadgeStaleFailure") { - $script:updateAvailable = $true - $script:updateBadgeObservedAt = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - $script:updateBadgeExpiryMs - 1000L - $notify.Icon = $offlineUpdateIcon - $updateItem.Visible = $true - $updateItem.Enabled = $true - if ($notify.Icon -ne "offline-update" -or -not $updateItem.Visible -or -not $updateItem.Enabled) { - throw "stale case did not start from a dotted icon and available menu" - } -} try { $badgeCase = $Scenario.StartsWith("Badge") if ($badgeCase) { - $script:updateBadgeAttemptAt = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - Start-UpdateBadgeProbe + # Match production startup: the first tick launches the probe; later ticks + # maintain it. Do not charge first-use JSON/file initialization to reaping. + Initialize-UpdateBadgeReader + $script:updateBadgeAttemptAt = 0L + Update-TrayState if ($null -eq $script:updateBadgeProcess) { throw "badge probe did not start" } } elseif ($Scenario -eq "Online") { # First tick settles wasOnline and launches the probe through the real @@ -167,6 +160,17 @@ try { } $probe = if ($badgeCase) { $script:updateBadgeProcess } else { $script:startupProbeProcess } $childPid = $probe.Id + # Seed the stale presentation after startup, before the maintenance under test. + if ($Scenario -eq "BadgeStaleFailure") { + $script:updateAvailable = $true + $script:updateBadgeObservedAt = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - $script:updateBadgeExpiryMs - 1000L + $notify.Icon = $offlineUpdateIcon + $updateItem.Visible = $true + $updateItem.Enabled = $true + if ($notify.Icon -ne "offline-update" -or -not $updateItem.Visible -or -not $updateItem.Enabled) { + throw "stale case did not start from a dotted icon and available menu" + } + } $iconBeforeMaintenance = $notify.Icon $updateAvailableBeforeMaintenance = $script:updateAvailable $observedAtBeforeMaintenance = $script:updateBadgeObservedAt @@ -190,7 +194,20 @@ try { } if (-not $task.IsFaulted) { throw "badge overflow reader did not enforce the byte cap" } } else { - Start-Sleep -Milliseconds 500 + # Process.Start returning does not mean PowerShell has executed the PID write. + # Wait for this child's readiness before backdating its timeout and killing it. + $pidFile = $env:OCX_PROBE_TEST_PID_FILE + $readyDeadline = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + 5000 + $ready = $false + do { + if ($pidFile -and [System.IO.File]::Exists($pidFile)) { + try { $ready = @([System.IO.File]::ReadAllLines($pidFile)) -contains [string]$childPid } catch { $ready = $false } + } + if ($ready) { break } + if ($probe.HasExited) { throw "probe exited before reporting readiness" } + Start-Sleep -Milliseconds 20 + } while ([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() -lt $readyDeadline) + if (-not $ready) { throw "probe did not report readiness within 5s" } } if ($Scenario -ne "BadgeStaleFailure" -and $probe.HasExited) { throw "probe child exited before maintenance" } $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() diff --git a/tests/windows/windows-tray.test.ts b/tests/windows/windows-tray.test.ts index 68b99502d90..59e3127f362 100644 --- a/tests/windows/windows-tray.test.ts +++ b/tests/windows/windows-tray.test.ts @@ -519,6 +519,8 @@ describe("Windows tray packaging and command safety", () => { // with spaces or `&` even when quoted. const hangChild = join(directory, "hangchild.ps1"); writeFileSync(hangChild, [ + // Prove readiness is observed, not guessed from a fixed 500ms sleep. + ...(scenario === "Online" ? ["Start-Sleep -Milliseconds 1500"] : []), "$pidFile = $env:OCX_PROBE_TEST_PID_FILE", "if ($pidFile) { Add-Content -LiteralPath $pidFile -Value $PID }", ...(scenario === "BadgeOverflowStdout" ? ["[Console]::Out.Write('x' * 32768)"] : []),