Codex Computer Use leaves ScreenCaptureKit stream running at ~56 FPS, causing high WindowServer GPU on macOS
Summary
Codex Desktop's bundled Computer Use runtime can leave a ScreenCaptureKit stream running after the Computer Use interaction/session has ended. The stale stream continued producing roughly 55–56 frames per second with no consumer, driving WindowServer to about 50–60% CPU and 59% GPU on macOS.
Fully quitting Codex clears the condition. Closing the target application or ending the visible Computer Use interaction does not.
Environment
- macOS 26.5.2 (25F84), arm64
- Codex Desktop 26.721.41059 (build 5848)
- Bundled Codex CLI
0.146.0-alpha.3.1 - Computer Use runtime 26.721.1000502 (build 1000502)
- One active external display: 2560×1440 at 100 Hz
Reproduction
- Launch Codex Desktop on macOS.
- Use the bundled Computer Use capability to inspect/control another macOS app.
- Finish the interaction and fully quit the target app.
- Stop making Computer Use calls and inspect Activity Monitor using All Processes, sorted by
% GPU. - Inspect the running Computer Use helpers and recent
replayd/SkyComputerUseServiceunified logs.
The issue may be easier to reproduce after more than one Codex task/thread has loaded Computer Use. In this reproduction, two SkyComputerUseClient event-stream mcp processes remained connected to one SkyComputerUseService.
Actual behavior
After the target app had fully exited:
- No target-app process remained.
WindowServerwas approximately 56.6% CPU and 59.4% GPU.- Codex's own GPU process was below 1% GPU at the same time.
- One
SkyComputerUseServiceand twoSkyComputerUseClient event-stream mcpprocesses remained. replaydstill reported an active capture session attributed to theSkyComputerUseServicePID.
Representative sanitized log evidence:
FigVideoQueueGMStats: 334 frames enqueued in the last 6 seconds
recent frames: enqueued: 335, displayed: 0, evicted: 335
SCCaptureSession setupHealthMonitor: screenframeCount=279
fetchShareableContent... currentProcessID=<SkyComputerUseService PID>
The screenframeCount samples were taken five seconds apart, corresponding to roughly 55–56 captured frames per second. Frames continued to be generated even when none were displayed and all were evicted.
Resetting the current node_repl Computer Use client stopped one capture stream, but another stream continued at the same rate. WindowServer remained around 46–49% CPU during a separate 10-second top sample with no UI automation calls being made.
No GPU reset, GPU hang, AGX timeout, GPU fault, or Metal fault appeared in the same one-hour log window.
Expected behavior
When the Computer Use tool call/turn/session ends and there is no active consumer:
- its ScreenCaptureKit stream should stop;
- stale per-thread MCP clients should be disconnected or reaped;
SkyComputerUseServiceshould not continue producing frames that are immediately evicted;WindowServerGPU usage should return to its normal idle level.
Workaround
Fully quit Codex with Cmd-Q, wait for SkyComputerUseService and SkyComputerUseClient to exit, and reopen Codex.
Suggested fix
- Tie each ScreenCaptureKit stream to an explicit consumer/session lifetime.
- Stop and invalidate the stream when the last consumer disconnects or a turn ends.
- Add an idle guard: if frames are continuously evicted with no displayed/consumed frames, tear down the stream.
- Reap stale
event-stream mcpclients and prevent multiple abandoned clients from keeping capture alive. - Add a regression test that verifies helper count and active ScreenCaptureKit streams return to baseline after a Computer Use turn ends.
Related issue
- #29157 tracks leaked
SkyComputerUseClient turn-endednotifier helpers. This report is related but distinct: the leaked clients here areevent-stream mcpclients and an active ScreenCaptureKit stream continues rendering at high frame rate, causing sustainedWindowServerGPU load.
5 Comments
Confirming this on a newer build, with per-client CPU measurements that may help isolate the stranded stream.
Environment
| | Reported in issue | This report |
|---|---|---|
| Codex Desktop | 26.721.41059 (5848) | 26.727.51351 (6119) |
| Bundled CLI | 0.146.0-alpha.3.1 | 0.146.0-alpha.9.2 |
| CU runtime | 26.721.1000502 | 26.727.1000550 |
| macOS | 26.5.2 (25F84) | 26.6 (25G70) |
| Hardware | — | M2 Ultra, 128 GB |
| Display | 1× 2560×1440 @ 100 Hz | 1× 6016×3384 Retina 6K |
Different display configuration, same symptom — so this does not appear to be specific to a particular refresh rate or external-display setup.
Observed
SkyComputerUseClient event-stream mcpclients accumulate against a singleSkyComputerUseServiceand are never reaped. The original report saw 2; this machine currently has 10, all children of the ChatGPT.appcodex ... app-server(pid uptime 21h39m).The part that may be diagnostic: per-client CPU is constant
Each leaked client burns a near-identical fraction of a core, independent of how long it has been alive:
| CPU time | Elapsed | Ratio |
|---|---|---|
| 10:10.75 | 12:47:56 | 1.33% |
| 10:09.45 | 12:47:49 | 1.32% |
| 10:08.29 | 12:47:44 | 1.32% |
| 10:06.94 | 12:42:05 | 1.33% |
| 7:35.56 | 9:52:52 | 1.28% |
| 7:34.06 | 9:52:55 | 1.28% |
| 2:20.56 | 3:27:18 | 1.13% |
| 0:19.52 | 30:40 | 1.06% |
| 0:06.41 | 10:58 | 0.97% |
| 0:03.60 | 5:58 | 1.01% |
The ratio does not decay as a client ages. That is consistent with each client holding its own fixed-rate periodic work item (e.g. a capture stream at a constant FPS) that continues after the consumer is gone, rather than a one-time cost or a slow drain. Practical consequence: total CPU cost scales linearly with the leak count — currently ~12% of one core, continuously, at 10 clients.
WindowServeron this machine has been sitting at 34–61% CPU with 2.2–3.9 GB resident across several days of observation, which is consistent with the mechanism described in this issue, though I have not independently confirmed the ScreenCaptureKit attribution here.Accumulation rate from a clean boot
Measured across a reboot boundary, so this is a clean-start rate rather than a long-tail accumulation:
event-streamclients/hour (10 clients at 21h39m app-server uptime)On the prior boot the accumulation was heavy enough to push a 128 GB machine into sustained memory pressure: swap file grew 3 GB → 4 GB with 3.2 GB in use, compressor at 19 GB, ~1.1 GB free. After reboot: swap 0, compressor 2.4 GB, 10 GB free. The reboot resolved it and the accumulation immediately began again, which points at lifecycle rather than any single runaway process.
Distinguishing this from the
turn-endedleakThis machine has zero
PPID=1orphans, so this is a distinct path from #26293 and #29157, even though it is the same binary:SkyComputerUseClient turn-endedhelpers, reparented tolaunchd(PPID=1)SkyComputerUseClient event-stream mcp, retained as live children of theapp-server, each holding ongoing periodic workBoth leak paths are reachable from the same install: the
turn-endednotifier is configured vianotify = [".../SkyComputerUseClient", "turn-ended"]inconfig.toml, while theevent-streamclients are spawned by the app-server itself.Related
SkyComputerUseClient mcpprocesses" alongsidenode_replandcodex app-server --listen stdio://helpersturn-ended/ PPID=1 variant described aboveQuitting and relaunching Codex clears the condition, matching the original report. Ending the Computer Use interaction does not.
Follow-up to my measurements above, with what I think is a sharper statement of the lifecycle bug.
Each leaked
event-streamclient is 1:1 with a thread, and is stranded exactly when that thread goes idle.How to recover the mapping
The client processes carry no thread identity — args are byte-identical (
SkyComputerUseClient event-stream mcp), and a 1-hour-old client is structurally indistinguishable from a 14-hour-old one (both: 7 threads, 3 pipes, 2systmsockets, same fd profile).state_5.sqliteandlogs_2.sqlitehave no column linking a thread to an OS pid.But the mapping is recoverable by timestamp. For each client, compute
spawn_time = now - etimeand compare againstrecency_atin thethreadstable:Δ is exactly 0 for 8 of 12 clients, across spawn times spanning 14 hours. That is not coincidental alignment.
The four non-matches are the confirming case
Every unmatched client belongs to a thread whose
recency_atadvanced past the client's spawn time:So the correlation holds in both directions:
spawn_time == thread.recency_at— the thread's last activity is frozen at the moment its client spawned. Nothing has happened in that thread since. The client is stranded, yet it keeps its periodic work item alive indefinitely (~1.0–1.33% of a core, per my earlier comment).recency_athas moved on. The client is live.Why this may be useful for a fix
This narrows the bug from "helper processes accumulate" to something more specific: the client is spawned per-thread and is never torn down when its thread goes idle or is navigated away from. The teardown appears to be missing at exactly the point where a thread stops being the active one — not at app quit (which does clean up), and not at turn end (which is the separate #26293 / #29157 path).
It also means the leak count is bounded by "number of threads that have ever used Computer Use this app session," which matches what I observe: the clients accumulate roughly in step with distinct threads used, not with turns taken.
Side note for anyone working around this locally
The same correlation makes a safe local reaper possible without guessing: only kill clients whose spawn time matches an idle thread's
recency_at. Live threads fail to match automatically, so they are protected without needing to be identified. (Read the DB with?immutable=1to avoid lock contention with the running app.) This is a workaround, not a fix — the teardown still belongs in the app.Correction to my previous comment. I suggested there that the spawn-time correlation "makes a safe local reaper possible." I tested that, and it does not work. Retracting it, and the reason why turns out to be more informative than the workaround would have been.
The experiment
Killed one stranded client with
SIGTERM— pid 17673, idle 14h42m, matched to a thread whoserecency_athad been frozen since the client spawned.The kill itself was clean: the process exited,
SkyComputerUseServicestayed up, the app-server stayed up, and the other ten clients were untouched. No collateral.But the app-server spawned a replacement within about one second, with the same parent. Watched for three minutes afterward:
Client count held at 11 → 11. The replacement accumulated 1.53 CPU-seconds over 166 seconds — 0.92% of a core, sustained — matching the 1.28–1.33% of the client it replaced. It is not a transient reconnect that idles out.
There is also a second-order problem. The replacement's spawn time is now, so it no longer coincides with any idle thread's
recency_at:So killing a stranded client converts it into one the correlation can never identify again. Applying the approach across all stranded clients would yield the same number of processes, the same CPU cost, and no way to detect them afterward. Do not use the workaround I described.
What this says about the bug
These clients are supervised, not merely leaked. The app-server maintains one per thread and restarts it when it dies.
That reframes the defect. It is not "teardown is missing at process exit" — teardown is being actively defeated by a supervisor that is doing what it was designed to do. The missing step is earlier: the thread is never deregistered as needing a Computer Use client when it goes idle, so the supervisor correctly keeps honoring a registration that should have been released.
That also explains cleanly why quitting the app is the only thing that clears the condition, as the original report noted: quitting destroys the supervisor along with the whole registration set. Nothing short of that can win, because anything short of that is arguing with a supervisor.
I do not think there is a viable user-side mitigation, and I would discourage anyone from building one — a scheduled reaper would burn CPU killing processes that immediately return.
Everything in my earlier comments still holds: the 1:1 spawn-time ↔
recency_atcorrelation (Δ = 0 across 8 of 12 clients), the per-client CPU cost, and the accumulation rate. The correlation remains a good way to observe which thread owns which client. It is just not a basis for acting on them.Cross-link to #37062 — fresh WindowServer SIGABRT repro on 26.6 + cua_node dyld loop
Adding a same-day observation from #37062 (the dedicated crash issue): on macOS 26.6 (25G72, xnu
12377.161.13~4), the leak described here is reproducibly crashingWindowServerviaWSCaptureCreateIOSurfaceMachPortForWindowList → WSIOSurfaceDebugTallyAndAbort. Kernel emits "IOSurface count of 65024 approaching limit of 65536" twice in the minutes before the abort.A separate, possibly-related failure mode: 21
node-*.ipsforcua_node(all coalesce to slice87fbc746-7d47-3fd8-b0a3-97018cbf954b) abort indyld4::prepareafter 7.2 s, suggesting the ChatGPT.app bundle's dyld pointer cannot resolve to a real libnode location. May be downstream of the WindowServer abort; may be independent — root cause not isolated yet.Full stacks, PIDs, kernel timeline, workaround, frequency, and suggested fixes are in #37062. Happy to fold any of it back into this thread instead if a maintainer prefers.
coalitionName: ai.lark-channel-bridge.bot.codexon thecua_nodecrashes confirms these were spawned from a Codex session.Confirming this on a newer Computer Use runtime and through a non-Codex-Desktop frontend that launches the official Codex app-server.
Environment
codex app-server --enable goals@oai/sky0.6.2Observation
After a Paseo-managed Codex agent used Computer Use for a UI inspection and returned to idle, there were no further Computer Use tool calls, but the capture pipeline remained active:
SkyComputerUseServicestayed at about 23.8% CPU.replaydremained active.SkyComputerUseClient event-stream mcpprocesses remained as children of Codex app-server processes.SkyComputerUseClient turn-endedhelpers, so this is theevent-stream mcppath described here, not the separate notifier leak in #26293.macOS continued to treat the display as actively captured after the agent turn ended. One secondary effect was that macOS suppressed desktop notification banners while its global “show notifications when mirroring or sharing the display” option was disabled.
Lifecycle detail
Paseo keeps the Codex app-server and its
node_replkernel alive between turns. In@oai/sky0.6.2, the macOS path lazily caches a singleton Mac client/native-pipe transport, but the exported Sky API and Mac client declarations expose noclose,dispose, ordisconnectoperation. The bundled Computer Use skill also defines no end-of-task cleanup.In an earlier controlled check, resetting and then terminating the initiating
node_repland its session-specific Computer Use client did not stop frame decoding in the shared service. This is consistent with the supervised-client behavior already documented in this issue: killing an individual client is not a durable workaround.Recovery check
I sent a normal
SIGTERMonly toSkyComputerUseService; Paseo, Codex app-server, and all unrelated processes remained running. After 15 seconds:SkyComputerUseServicewas absent;replaydwas at 0.0% CPU;This clears the stale state but is only a reset, not a lifecycle fix. The service can start again on the next Computer Use request.
This reproduction suggests the regression test should also cover app-server consumers outside Codex Desktop: after the last Computer Use request/turn becomes idle, the ScreenCaptureKit stream and per-thread event-stream registration should be released even when the app-server itself remains alive. A supported macOS transport/session teardown API would also make frontend-level cleanup possible.