My home server runs about thirty containers: this blog, a NAS dashboard, monitoring, a handful of self-hosted things. On top of that I do my actual development on it over VS Code Remote-SSH, against a 36 GB workspace holding four hundred thousand files.

One afternoon in mid-July it cooked itself until it stopped responding.

Three days of digging later, the culprit turned out to be the editor. Not any of those thirty containers.

The symptom: 90°C pinned for forty minutes#

While chasing an earlier power-loss problem I had thrown together a script that logs CPU temperature and load once a minute. That script caught this:

console
15:12  cpu=90.1C  load=8.65
15:22  cpu=90.0C  load=32.53
15:31  cpu=90.0C  load=84.42
15:43  cpu=90.0C  load=156.54
15:50  cpu=90.0C  load=196.01   ← cron can no longer get scheduled

Temperature nailed to 90°C, load climbing from 8 all the way to 196, and eventually even a once-a-minute cron job couldn't get a slot. Forty minutes of that and the whole machine stopped answering.

The journal was spitting this out every four seconds right up to the end:

console
amdgpu 0000:79:00.0: amdgpu: SMU: I'm not done with your previous command

The is the little microcontroller that manages power and temperature on the chip. If even that can't get scheduled, the CPU is as busy as it gets.

Power was easy to rule out: the machine sits behind a UPS, and the UPS logged OL the whole time — mains steady, battery full. This was not a power cut.

The answer, up front#

Three culprits, all of them part of the VS Code Remote-SSH bundle:

CulpritCostWhat I did
rust-analyzer re-running cargo check forever against a project that cannot compile8.3 coresCap jobs, turn off allTargets
Foam periodically firing ripgrep across four hundred thousand files245 rg processes at peakDisable the extension
RustDesk forking ps aux once a second1.5% of the machineLeft it alone — it's noise

The rest of this is how I got there.

This was hard to find because the evidence lies#

The temperature didn't spike, it crept:

DateMinutes above 85°CPeak
07-16083.9°C
07-1716796.2°C
07-1839796.4°C
07-198194.0°C
07-2061796.1°C

07-16 was completely fine, 07-17 was not. Something arrived on 07-17 and never left.

The problem is that the next three leads were all convincing, and all three were wrong.

Trap one: the sensors were lying#

Here's what the machine was reporting at the time:

console
Tctl: 94.6°C   PPT: 5-22W   load: 10-13

An 8700G drawing 5 to 22 watts — which is idle — should sit around 45 to 55°C. This was 94°C. Power that low with heat that high points in exactly one direction: the heat isn't getting out. Dried-out thermal paste, or a cooler that isn't seated properly. The timeline even cooperated: I'd had the case open in early July while chasing the power problem, so knocking the CPU fan cable loose was entirely plausible.

So the call at the time was to repaste. I never did it, purely out of laziness — the cooler is a , dual-tower coolers are a pain to pull and reseat, and I'd have had to go find where I put the thermal paste.

Not doing it turned out to be the right call. After a reboot, same machine, same cooler:

Before rebootAfter reboot
CPU94.9°C54.5°C
GPU61°C38°C
NVMe57°C42°C

And that's under load — load average 12, every container running — still holding 55°C.

WARNING

When a sensor is broken, the numbers it gives you are fiction That "PPT is only 5 to 22W" reading was never real. The SMU had already hung by then, which is exactly what amdgpu was complaining about every four seconds. Once the SMU is gone, every value that travels through it is untrustworthy, power draw included. The CPU really was burning at full tilt; the heat was getting out fine.

Reasoning from a broken instrument is more dangerous than having no instrument at all, because the output still looks like evidence.

Trap two: rebooting fixed it#

After the reboot I went back through the process list and found one that had been eating CPU for days:

console
msedge   395% CPU   ← four cores

The causal chain assembled itself instantly: a runaway Edge tab pins the CPU, the CPU hits Tjmax and throttles hard, throttling backs up the work queue, load goes 8, 32, 84, 196, the SMU gets starved, the system locks up. And since killing Edge via the reboot made everything fine again, it looked verified.

Except I'd been in there myself. I connected over RustDesk, Edge wasn't running, and CPU usage was only 30 to 50%.

If it runs hot without Edge, Edge isn't the cause. "Rebooting fixed it" only proves the culprit isn't a long-running service — it says nothing about which one it is. Treating that as confirmation is the closest I came to going down the wrong road entirely.

Trap three: 30 to 50% CPU doesn't look like overheating#

This is the most counterintuitive one, and it's also the answer. sar unpacks it below.

How to chase an event that already ended#

The event was over and the processes were long dead, and I didn't want to wait for it to happen again before catching it. Two things had been quietly keeping history for me.

sar: every minute of past CPU breakdown is already on disk

top only shows you now. But this box has installed. Comparing the calm 07-16 against the meltdown on 07-20:

console
07-16 (fine)     %user  7%   %system  3%   %idle 87%
07-20 (94°C)     %user 46%   %system 18%   %idle 32%   runq-sz 20-24   blocked 0

The 30 to 50% I'd seen was %user at 46%. Add the 18% of %system and 68% of the CPU was actually burning, and had been for hours.

On an 8700G, sustained 68% is roughly eleven threads computing without pause. That number doesn't feel alarming, but for a sustained load it is more than enough to push an APU to its thermal ceiling. A 100% spike for ten seconds is nothing; 68% for three hours will hit Tjmax.

Reading iowait 0.04%, blocked 0 and runq-sz 20-24 together also proves it wasn't stuck on I/O. Processes parked in also produce a huge load average, but that kind of load leaves the CPU idle. Here there really were twenty-odd runnable threads queued up.

Every container was innocent

After the reboot all thirty-five containers came back, and %user sat at 6.9% — identical to the calm day.

Whatever was eating that 46% was not a container. It was something that doesn't come back on its own after a reboot: something in the desktop session.

netdata's apps.plugin can be replayed

This box runs netdata, and its keeps per-process history. Subtracting each app group's "hot window" from its "quiet window":

console
delta        hot       quiet   app
+829.3     834.7        5.4   sshd     ← 8.3 cores
  +5.0      45.5       40.4   dockerd
  +4.7       9.0        4.3   rustdesk

The answer had been sitting on disk the whole time. No need to wait for a repeat.

sshd jumping from 5.4% to 834.7% does not mean the SSH service itself is misbehaving. apps.plugin attributes unrecognised processes to their parent's group, and I connect over VS Code Remote-SSH:

text
sshd
└─ code-server
   ├─ bootstrap-fork --type=fileWatcher
   └─ bootstrap-fork --type=extensionHost
      ├─ tsserver.js ×2
      ├─ typingsInstaller.js
      ├─ jsonServerMain
      └─ claude ×2

As far as netdata is concerned, the entire VS Code Remote-SSH stack is sshd. Grouping by user agrees: my account went from 74% to 928.7% while root barely moved. So does the timeline:

Timesshd groupMatching event
07-16 18:5412.7%Calm day, 63°C
07-17 13:18227.8%First crossing of 85°C was 13:05
07-18 16:25380.1%397 minutes above 85°C that day
07-19 15:10308.7%The load=196 event at 15:43
07-19 16:3811.5%Back down after the reboot
07-20 17:480.0%Zero after shutdown

I installed both of these ages ago and never look at them. Reconstructing this without waiting for a repeat came down entirely to them: sysstat for the CPU breakdown, netdata's apps.plugin for the per-process history.

Culprit one: rust-analyzer looping cargo check forever#

Once it was narrowed to the VS Code bundle, I went straight to its own logs. The biggest file was rust-analyzer's, and it was wall-to-wall:

console
ERROR Flycheck failed to run: cargo check --workspace
error: failed to run custom build command for `soup3-sys`
error: failed to run custom build command for `javascriptcore-rs-sys`
error: failed to run custom build command for `gobject-sys` / `glib-sys` / `gio-sys`

The full command:

bash
cargo check --workspace --keep-going --all-targets \
  --manifest-path ~/Server/tauri-plugin-sidecar/Cargo.toml

That's a plugin project, and on Linux Tauri draws its windows through the system's GTK and WebKit. At build time the *-sys crates go looking for those libraries' development files on the host.

Except this is a headless server that has never had a desktop environment on it, so none of them are there. Here's what the log was missing:

Missing libraryWhat it does
gdk-3.0Windowing and drawing primitives
javascriptcoregtk-4.1, libsoup-3.0WebKit's JS engine and HTTP layer
glib-2.0, gobject-2.0, gio-2.0GTK's object system and I/O
cairo, pangoVector drawing and text layout
gdk-pixbuf-2.0, atkImage decoding and accessibility

And the same set of failures repeats thirty-five times in the log. That's the retry count.

In other words this cargo check isn't slow, it's unfinishable: every round falls over in the same place, and then starts again from scratch. That's how the loop closes:

CAUTION

--keep-going --all-targets is a lethal pair --keep-going means "don't stop on an error, keep building everything else". --all-targets means "build lib, bin, test, bench and example, all of them".

Together they mean: knowing full well it cannot compile, walk every target in the entire workspace from beginning to end anyway. And because a failed build script caches nothing, every retry is a complete rebuild that saturates every core.

This also explains two things that hadn't made sense: why it only started after 07-17 (the day I started working on that Tauri project) and why a reboot always fixed it (the VS Code session dies, and rust-analyzer with it).

The fix#

The real fix is installing those system dependencies, or simply not opening that project on the server. But the more important part is putting a ceiling in place so that the next loop, whatever causes it, can't cook the machine:

toml
# ~/.cargo/config.toml
[build]
jobs = 6
json
// rust-analyzer: skip test/bench/example, and cap its own threads
{
  "rust-analyzer.check.allTargets": false,
  "rust-analyzer.numThreads": 4
}

The jobs line is the only one that actually guarantees an upper bound. Everything else treats symptoms.

Worth noting: my files.watcherExclude was already set up properly, with the large directories excluded. So file watching was not the problem here — it was purely cargo. That's worth saying out loud, because in most "VS Code eats resources" discussions file watching gets treated as the only suspect.

Culprit two: Foam spawning hundreds of ripgrep processes#

I thought that was the end of it, but a few days later the CPU started sawing up and down again. Sampling the process count repeatedly:

console
16 → 36 → 6 → 5 → 15 → ... → 245

Every one of them had the same fingerprint:

console
rg (--files ... .foam ...)
   └── PID 1220855  = the ext host VS Code had just respawned

It was , periodically firing rg --files across the entire workspace. And my workspace looks like this:

36GB
Workspace size
405k
Files
16dirs
node_modules
plus 5 Rust target/ dirs

WARNING

Killing processes does not fix this I killed the ext host that Foam was running in. VS Code immediately spawned a replacement, Foam came back with it, and rg started flooding again. I watched that loop happen in real time.

The only thing that works is disabling the extension itself. And renaming its folder on disk still requires a Reload Window — the copy already loaded into memory doesn't care what you did to the filesystem.

After disabling it, rg went to zero and the temperature dropped from 90°C back to 69°C.

This symptom isn't unique to me. VS Code has issues for rg hitting 900% CPU and sustained high rg CPU, and the Cursor community has a thread titled Cursor spawns hundreds of rg processes describing almost exactly my 245.

What those threads have in common is that nobody identifies a root cause; most of the replies speculate that ripgrep itself got slower. But rg isn't broken. It's just being invoked over and over. The thing burning CPU is rg; the hand pressing the button is something else — with this class of problem you go looking for the caller, not the callee.

Culprit three: RustDesk, which is only noise#

I ran a thirty-second process-spawn capture, recording everything new that appeared:

console
11× sh   ← spawned by rustdesk
 7× ps   ← spawned by rustdesk

RustDesk 1.3.1 forks one or two sh processes per second to run ps aux, using them to manage its own children and detect user sessions. Over four days that added up to nineteen hours of CPU time, and it does this with nobody connected. Restarting it changes nothing.

Across a sixteen-core machine that's 1.5%. Nowhere near enough to boil a CPU, and I need it to get a remote desktop into the box, so it stays. It's written up here only because it came third in the netdata delta table, and ruling it out cost more time than it was worth.

Two safety nets added afterwards#

Capping the temperature limit at 85°C#

I went into the BIOS and pulled the CPU's thermal limit down from somewhere around 95°C to 85°C.

Not because cooling is inadequate, but because this incident proved that "something loops forever and saturates every core" is a thing that happens here. A lower ceiling means the chip throttles earlier in that situation: the machine gets slower, but it doesn't sit at 90-plus with every core pegged for forty minutes.

The setting lives under . Day to day I cannot tell the difference, because nothing normally gets near 85°C — the baseline I measured afterwards was 72.6°C with an 83°C peak, 40W average and 64W peak against a roughly 88W ceiling, 4.6GHz all-core and 4.9GHz single-core. All normal.

Beyond that you could undervolt with Curve Optimizer, which runs cooler and cheaper with no performance loss. But this machine holds data, and an unstable undervolt shows up as random lockups and segfaults. Stability beats optimisation here, so I'm not doing it.

Changing how I do remote development#

I also changed how I work, because neither of the two real culprits was an accident — they're what the remote development architecture implies. Language servers, file indexing, extensions, search: all of it runs on the machine with far less thermal headroom than a desktop. The bigger the workspace the worse it gets, and mine is 36 GB across four hundred thousand files, which is close to worst case.

The split now is: VS Code Remote-SSH at home over the LAN, where I'm sitting next to the machine and can see and kill anything that catches fire; Zed when I'm connecting from somewhere else for hours, because all three culprits above are the kind that build up quietly while you're connected — and not being in the room is exactly the worst case.

參考連結