[BUG] Cowork (Linux): idle QEMU VM burns ~7-8% of a core continuously — q35's default HPET pulses a masked IRQ line ~1000×/s
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
As you can probably tell, Claude (Opus 5) wrote this bug report. I can personally verify that the qemu vm uses 7ish percent cpu regardless of if Claude is doing anything, and that it makes my laptop annoyingly hot, but I didn't verify any of his investigation.
This is a Claude Desktop / Cowork bug, not a CLI bug. The template is CLI-shaped, so the version and terminal fields below are answered with their desktop equivalents. Suggested labels: area:cowork, platform:linux.
On Linux, the Cowork workspace VM is launched with -machine q35,accel=kvm,memory-backend=mem0 and no hpet=off. QEMU therefore emulates an HPET in userspace, which fires roughly 1000 times per second and pulses a legacy interrupt line into a guest that never unmasks it.
The guest receives none of these interrupts. The host pays for every one. The result is ~7-8% of a CPU core burned continuously, from the moment a Cowork conversation first starts the VM until the app exits, on a VM that is doing nothing at all. The VM is not torn down when a session ends, so the measurement below is of an idle VM that had outlived its last session by days.
Measured over one VM lifetime of 3 days 15 hours:
| | CPU consumed |
|---|---|
| Guest OS (all work it has ever done) | 446 s |
| QEMU process on the host | 22,071 s |
| Overhead ratio | 49× |
That is about 2 core-hours per day, indefinitely. On a laptop it is a meaningful battery cost.
Where the CPU goes
Per-thread over a 10-second window (1000 jiffies = 100% of one core):
862930 .qemu-system-x8 71 jiffies 7.10% of one core <- main loop
862937 CPU 1 jiffy 0.10% of one core <- vCPU
It is the main loop, not a vCPU thread. That also rules out KVM halt-polling: halt_poll_ns is at the 200000 default, but the vCPU thread is idle, so there is no HLT storm to amplify.
perf identifies the source. hpet_timer is the only device timer anywhere in the profile:
hpet_timer 121 <- the only device timer present
qemu_clock_run_all_timers 1933
timerlist_run_timers 1781
kvm_vm_ioctl 1895
kvm_set_irq 1568 <- the KVM_IRQ_LINE_STATUS pairs
rtc_ / mc146818 0
i8254 / i8259 / pit_ 0
apic_ 0
Note that the injection costs about 12× more than the timer callback that triggers it, and all of it is wasted.
The guest confirms it from the other end. Every legacy IRQ line is flat while QEMU pulses ~1000×/sec:
LOC: +197 local APIC timer
27/28: +19 virtio-blk
IRQ 0 (timer): +0
IRQ 8 (rtc0): +0
Why 1 kHz costs 8% of a core
The timer itself is ~1% of the profile. The expense is what each expiry drags behind it:
poll machinery (fdget, sock_poll, tcp_poll, do_sys_poll) ~13.5%
syscall entry/exit ~7.8%
glib source iteration ~6.5%
scheduler + PSI accounting (sleep/wake churn) ~6.6%
clock_gettime (vdso + libc) ~2.6%
slirp socket re-polling ~2.1%
HPET device model itself ~1.0%
Every HPET expiry wakes the glib main loop, which iterates all its sources, rebuilds the pollfd array, re-arms a host hrtimer, and asks SLIRP to re-poll every emulated socket — which is why sock_poll and tcp_poll are top-five kernel symbols on a VM doing no networking whatsoever.
Roughly 99% overhead, 1% device emulation.
What Should Happen?
An idle Cowork VM should consume approximately no host CPU. Nothing in the guest reads a 1 kHz timer, so QEMU should not be emulating one.
Proposed fix — add hpet=off to the -machine argument:
-machine q35,accel=kvm,memory-backend=mem0,hpet=off
(-no-hpet is the equivalent on older QEMU, deprecated in QEMU 9.x in favour of the machine property.)
This is safe for this guest, and the guest's own configuration says so directly:
- clocksource is
kvm-clock, not HPET - both clockevent devices are
lapic-deadline— in-kernel, requiring no exits - HPET appears only as the broadcast device, which is never actually needed here because
/sys/devices/system/cpu/cpuidle/current_driverisnone— there are no deep C-states that would stop the local APIC timer and require a broadcast fallback /dev/hpetdoes not exist in the guest; nothing in userspace uses it
Nothing in the guest would miss it.
Secondary suggestion: -netdev user (SLIRP) accounts for another ~2% directly (slirp_pollfds_poll, slirp_pollfds_fill_socket, net_slirp_poll_notify) plus a good share of the ~5% in sock_poll/tcp_poll, because SLIRP re-fills and re-polls its socket list on every main loop iteration. Removing the HPET wakeups drops the iteration rate from ~2000/sec to roughly the real event rate (~5/sec), so this largely resolves itself. But if guest networking is not required for the workspace, or could be carried over the existing vsock transport, dropping the user-mode netdev would remove the rest.
Error Messages/Logs
=== ps: the idle VM process (3d15h elapsed, 6h07m of CPU) ===
PID ELAPSED TIME %CPU NLWP
385038 3-15:07:45 06:07:51 7.0 7
=== QEMU command line as launched by cowork-linux-helper (1.46388.2) ===
=== note the absence of hpet=off / -no-hpet ===
qemu-system-x86_64 -name claude-cowork-vm
-sandbox on,obsolete=deny,elevateprivileges=deny,spawn=deny,resourcecontrol=deny
-machine q35,accel=kvm,memory-backend=mem0
-cpu host -smp 2 -m 4096M
-netdev user,id=net0 -device virtio-net-pci,netdev=net0,bus=pcie.0,addr=0x1
-display none -vga none
... (virtio-blk, vhost-vsock, vhost-user-fs, virtio-serial, virtio-rng, qmp)
=== strace -c -f, 10 seconds, idle VM ===
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
79.33 1.523912 76 20008 ioctl
10.60 0.203631 10 19909 ppoll
0.06 0.001064 18 57 write
0.04 0.000728 12 58 io_uring_enter
0.03 0.000585 10 58 read
0.02 0.000320 17 18 1 futex
~2000 wakeups/sec. Only 57 writes and 58 reads in ten seconds, so no real I/O
is driving this.
=== the ioctl: always assert/deassert pairs on the kvm-vm fd ===
ioctl(30, KVM_IRQ_LINE_STATUS, 0x7fff383c76f0) = 0
ioctl(30, KVM_IRQ_LINE_STATUS, 0x7fff383c76f0) = 0
ioctl(30, KVM_IRQ_LINE_STATUS, 0x7fff383c76f0) = 0
...
fd 30 -> anon_inode:kvm-vm
20,008 ioctls / 2 = ~1000 interrupt pulses per second
=== ppoll timeouts: sub-millisecond, many already expired ===
{tv_sec=0, tv_nsec=0} <- timer already overdue
{tv_sec=0, tv_nsec=2156}
{tv_sec=0, tv_nsec=8008}
{tv_sec=0, tv_nsec=173416}
{tv_sec=0, tv_nsec=265320}
{tv_sec=0, tv_nsec=307454}
{tv_sec=0, tv_nsec=458150}
=== perf record -F 999 -g on the main loop thread, 15s, flat profile ===
4.29% fdget [kernel]
2.75% entry_SYSRETQ_unsafe_stack [kernel]
2.40% syscall_return_via_sysret [kernel]
2.29% __vdso_clock_gettime [vdso]
2.25% g_main_context_check_unlocked libglib-2.0
2.18% do_sys_poll [kernel]
1.94% _raw_spin_lock_irqsave [kernel]
1.81% g_mutex_lock libglib-2.0
1.37% net_slirp_poll_notify qemu
1.33% sock_poll [kernel]
1.21% g_main_context_prepare_unlocked libglib-2.0
1.09% slirp_pollfds_poll libslirp
1.05% slirp_pollfds_fill_socket qemu
call chain: qemu_main_loop -> main_loop_wait -> qemu_poll_ns -> ppoll
=== device-model frames across all sampled stacks ===
hpet 121 / 21 <- present on both versions tested
rtc_ 0
mc146818 0
i8254 / i8259 / pit_ 0
apic_ 0
kvm_set_irq 1568 / 253
=== guest side: nothing is arriving (5 second window) ===
LOC: +197 local APIC timer
27/28: +19 virtio-blk
CAL: +30
RES: +4
IRQ 0 (timer): +0
IRQ 8 (rtc0): +0
=== guest timer configuration (Anthropic's shipped image) ===
kernel: 6.8.0-136-generic
clocksource: kvm-clock
clockevent0/1: lapic-deadline
broadcast: hpet
cpuidle current_driver: none
/dev/hpet: does not exist
LOC rate: 6.7 ticks/sec/cpu (tickless working correctly)
=== guest lifetime CPU accounting (3d15h) ===
guest uptime: 313,371 s
summed idle: 626,296 s (of 626,742 possible across 2 vCPUs)
guest busy: 446 s = 0.14% of one core
QEMU host CPU: 22,071 s = 49x the guest's total
Steps to Reproduce
Reproduced identically on Claude Desktop 1.40609.1 and 1.46388.2, three weeks apart, on two separately booted VMs.
- On Linux, open Claude Desktop and start a Cowork conversation. This is what launches the workspace VM — it does not start at app launch. (I have not determined whether merely opening a Cowork conversation is sufficient, or whether the conversation must actually exercise the VM. Either way, the VM is running from that point on.)
- Let the session go idle, or close the conversation entirely. The VM is not torn down when a session ends — it keeps running until the app exits. Do not run any further Cowork work.
- Observe the QEMU process. CPU time accrues steadily and indefinitely, independent of session activity:
```
ps -o pid,etime,cputime,pcpu,nlwp -p $(pgrep -f 'name claude-cowork-vm')
PID ELAPSED TIME %CPU NLWP
385038 3-15:07:45 06:07:51 7.0 7
```
That sample is a VM that had outlived its last session by days.
- Confirm it is the main loop thread rather than a vCPU thread:
````
top -H -b -n 2 -d 10 -p $(pgrep -f 'name claude-cowork-vm') | tail -12
The main loop thread shows ~7-8% of a core; the vCPU thread shows ~0.1%.
- Confirm the wakeup rate and identify the ioctl:
````
sudo timeout 10 strace -c -f -p $(pgrep -f 'name claude-cowork-vm')
sudo timeout 3 strace -e trace=ioctl -p $(pgrep -f 'name claude-cowork-vm')
~20,000 ioctl + ~20,000 ppoll per 10 seconds, the ioctl being KVM_IRQ_LINE_STATUS in assert/deassert pairs on the kvm-vm fd.
- Confirm the guest receives none of them. Inside the VM, sample
/proc/interruptsover 5 seconds and observe IRQ 0 (timer) and IRQ 8 (rtc0) both flat at+0while QEMU pulses roughly 5000 times.
- Confirm the source:
````
sudo perf record -F 999 -g -t <main-loop-tid> -- sleep 15
sudo perf report --stdio --no-children
hpet_timer is present; rtc_, mc146818, i8254, i8259 and apic_ are all at zero samples.
- Confirm it is not configuration on the reporter's side — the command line has no
hpet=off, andstringson the shippedcowork-linux-helperbinary contains zero occurrences ofhpet(and one ofq35), so the property is never set in either direction and QEMU's q35 default of HPET-enabled applies.
Claude Model
None
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
Claude Desktop 1.46388.2 (Linux x64) — Desktop/Cowork, not the CLI. Also reproduced on 1.40609.1.
Platform
Anthropic API
Operating System
Other Linux
Terminal/Shell
Other
Additional Information
This is not packaging-specific
Reported from NixOS, using a flake that repackages Anthropic's official Linux .deb. That is not a supported configuration, so I checked whether the packaging could be responsible. It is not.
The flake does not modify the application. No .patch or .diff files, no app.asar or JavaScript patching. It applies autoPatchelfHook (rewriting the ELF interpreter and rpaths) and runs the app in an FHS sandbox supplying qemu-system-x86_64, virtiofsd and OVMF at the paths the app probes. Nothing in it references hpet, q35, or any QEMU machine property.
The QEMU command line is composed by Anthropic's own helper. strings on the shipped cowork-linux-helper binary contains:
q35
,memory-backend=mem0
memory-backend-memfd,id=mem0,size=%s,share=on
claude-cowork-vm
claude-daemon-console
create_vm_config
[vm] starting %s (cid=%d, mem=%dMB, cpus=%d)
[cowork-linux-helper]
and no occurrence of hpet at all, on either version tested. The app never sets the property, so it inherits QEMU's q35 default — upstream behavior in every distribution's QEMU build.
The guest is Anthropic's shipped image. Kernel 6.8.0-136-generic, booted from claudevm.bundle/vmlinuz + rootfs.img as delivered by the CDN. Every guest-side finding is therefore identical for all Linux Cowork users.
Two genuine differences, stated for completeness:
- The QEMU binary here is nixpkgs'
qemu_kvmrather than a distribution package. HPET-on-by-default forq35is longstanding upstream behavior, so this should reproduce identically on supported distributions — but I have not verified against an Ubuntu install, and that check would take about thirty seconds for anyone who has one. - This host's kernel has an LSM hooking
ioctl(hook_file_ioctl_common, 1.12% of the profile), so each wastedKVM_IRQ_LINE_STATUScosts somewhat more here than on a stock kernel. This affects per-ioctl cost, not the wakeup rate. The rate is set by the HPET period and is host-independent; a stock kernel would see the same ~2000 syscalls/sec at a somewhat lower total.
Impact
- ~7-8% of a core, sustained, from the first Cowork conversation until the app exits
- ~2 core-hours per day per user
- The VM is not torn down when a session ends — it persists for the lifetime of the app. The 3d15h sample above is a VM that outlived its last session by days. So this is not "cost while working": it is a floor that one Cowork conversation switches on and only quitting the app switches off
- Measurable battery drain on laptops, and invisible to users because the guest correctly reports itself as idle
The only current mitigation is quitting the app entirely.
Prior art checked
No existing report covers this. The nearest issues are distinct:
- #22543 — Cowork VM bundle disk bloat and Electron-side memory growth; its CPU discussion concerns renderer/main/GPU processes, not QEMU
- #56362 — "Cowork rises CPU temperature to 83°C even when idle", Windows 10 in VirtualBox, closed as not planned with no root cause
- #26194 — VM process spins CPU on macOS/Apple Silicon, which uses Virtualization.framework rather than QEMU
- #87794 — Intel iMac, VM fails to boot and burns 120% CPU; a boot failure, not steady-state idle
A repository search for no-hpet, hpet=off and KVM_IRQ_LINE returns zero results.