A 27-byte telemetry response nobody reads freezes Claude Desktop every two days A developer traced Claude Desktop's recurring Linux freezes to a 27-byte telemetry response the app never reads, with Chromium holding a file descriptor open for each unread reply until the renderer exhausts them, according to a first-person account published after two months of debugging. Diagnosing the failure required a 1.5 TiB core dump; systemd-coredump wrote 92.4 GiB in five minutes before hitting its timeout and leaving no file, and the app's main.log only recorded "Main webview is unresponsive, will kill and reload" every 20 to 40 seconds. Chromium maintains the behavior is working as intended, and Anthropic's bot closed the developer's issue as "not about Claude Code. A 27-byte telemetry response nobody reads freezes Claude Desktop every two days TL;DR: Claude Desktop on Linux froze dead every couple of days, with empty logs. It took two months and a 1.5 TiB core dump to find out why. The app never reads a 27-byte telemetry reply, and Chromium keeps a file descriptor open for each one until the renderer runs out of them. Chromium says it works as intended, and Anthropic's bot closed my issue as "not about Claude Code". A couple of months ago I migrated from ChatGPT to Claude Desktop, which is yet another Electron :love: app. And every couple of days it dies, in one of two ways: either every element disappears and only the skeleton placeholders stay, or it just freezes: you click, and nothing happens. It's not that regular, cause the app has to stay up without restarts. And the fast update pace makes this less noticeable. But when it hangs, it picks the best time for it : So I used this opportunity to write a post about Chromium internals. Two months of monkey-patching two-months-of-monkey-patching One fine day I noticed that while claude was building something in the background, my whole machine was lagging like hell. I blamed the build and ran uptime . Load average 46, and uptime itself took 20 seconds to start. cat /proc/pressure/io : full avg10=85.30 avg60=74.12 avg300=28.47 total=2187342911 WHAT. THE. FUCK. Since when can a build saturate an NVMe like this? Switched to claude. Only the skeleton was there. My first thought was that the llm had done something stupid and killed itself. But something was still thrashing the disk. It turned out to be systemd-coredump saving the core and killing the disk. The renderer had crashed = SIGILL = a core dump: the kernel writes the whole memory of the process to a file. Since it's Chromium, the dump is small: only one and a half terabytes. More about why this chungus is so big a bit later. And the dump is basically RPC, because the kernel starts systemd-coredump and pipes the memory to it. A pipe can't seek, so the pages nobody ever touched can't be skipped and go out as real zeros. I don't have a spare 1.5 TiB, but systemd-coredump tried anyway: it wrote 92.4 GiB in five minutes, hit its timeout, and left no file. Teamwork. Ok. But why did the renderer crash in the first place? All the app's main.log had was "Main webview is unresponsive, will kill and reload" every 20 to 40 seconds. Obviously nothing was reloaded. So I started patching Claude Desktop bit by bit. I capped core dumps at 4 GiB. The crash itself left nothing to read, because the app's stdout and stderr went to /dev/null , so I sent them to the journal and waited. 11 days later the app did kill its hung renderer, and Chromium left me this: FATAL:content/child/child thread impl.cc:380 Crashing because hung Cool. Good logs. Same story — dead renderer, SIGILL , core. Capped this time, it had one stack frame in it and still hung the laptop for a couple of minutes. It was useless, so I turned core dumps off for the app and attached a debugger that prints the JS stack when the app calls the renderer unresponsive. Let's wait for the next postcard. 13 days later the window died quietly: the same skeleton, no logs, and nothing from the debugger. Yay. It stayed like that for 18 minutes, Ctrl+R did nothing, and I restarted the app. So I added a log line for every death of the renderer, with its reason and exit code. 2 days later it died again, with "everything is fine": hang-stack main webview renderer gone: { reason: 'clean-exit', exitCode: 0 } The debugger had nothing to print, because nothing hung. It's just an exit, Chromium simply decided to quit. The app treats a clean exit as intentional and doesn't reload the window. That time it stayed dead for 15 minutes. So I made my patch reload the window. After that it just froze for a minute and came back. It annoyed me enormously, but no data was lost and the app didn't need a restart, so it was good enough. I got tired of adding logs a teaspoon at a time and added a batch: process exits, console errors, failed page loads, and Chromium's own verbose logs. Every time it looked different in the logs: a futex wait, the app killing a hung renderer, a failed GPU buffer allocation, a clean exit. None of the logs or stacks showed the cause. The core dump the-core-dump 8 days later it froze completely. A reply was streaming and stopped in the middle of the typing animation: half a letter hanging, the status stuck at "6s · 246 tokens". The app had been up for 35 hours. This time I was lucky: the renderer was hung and still alive, not dead like all the previous times. I ran gcore by hand and dumped the renderer and the browser process, which is Chromium's name for the app's main process. This dump goes to a regular file, not through a pipe, so the file is sparse: 1.5 TiB by size, 1.5 GiB on disk. If I emailed it to you, it would be a 1.5 TiB attachment. Now, why this chungus is so big. Almost all of it is not memory but address space, which Chromium reserves at startup and mostly never touches. The app runs on Electron 44, which is Chrome 152. Address space details Chrome 152 1376 GiB of that address space is a single reservation for the sandbox of V8, the JS engine. V8 stores pointers in the JS heap as offsets from the start of the sandbox, so a corrupted one still lands inside it and can't reach the rest of the process. For that to hold, V8 reserves the whole range in advance, 1 TiB for the sandbox itself and 352 GiB of guard regions around it. Chromium's allocator reserves two more pools of 16 GiB, and its GC for C++ objects another 16 GiB. These are the biggest reservations in the renderer's dump. | Size | What it is | |---|---| | 64 GiB | guard region in front of the sandbox | | 1 TiB | the V8 sandbox | | 288 GiB | guard region after the sandbox | | 32 GiB | two pools of PartitionAlloc, Chromium's allocator | | 16 GiB | Oilpan, the garbage-collected heap for C++ objects | | 4 GiB | the bottom of the address space, so that a small integer used as a pointer faults | A pointer to a buffer inside the sandbox is stored as an offset from the sandbox base. 243// Everywhere else use a 1TB sandbox. 244constexpr size t kSandboxSizeLog2 = 40; // 1 TB 245 endif // V8 TARGET OS ANDROID 246constexpr size t kSandboxSize = 1ULL << kSandboxSizeLog2; 247 248// Required alignment of the sandbox. For simplicity, we require the 249// size of the guard regions to be a multiple of this, so that this specifies 250// the alignment of the sandbox including and excluding surrounding guard 251// regions. The alignment requirement is due to the pointer compression cage 252// being located at the start of the sandbox. 253constexpr size t kSandboxAlignment = kPtrComprCageBaseAlignment; 254 255// Sandboxed pointers are stored inside the heap as offset from the sandbox 256// base shifted to the left. This way, it is guaranteed that the offset is 257// smaller than the sandbox size after shifting it to the right again. This 258// constant specifies the shift amount. 259constexpr uint64 t kSandboxedPointerShift = 64 - kSandboxSizeLog2; The first 4 GiB of the sandbox are the pointer compression cage, where JS objects live, and a pointer from one object to another is a 32-bit offset into it. These pointers are tagged in their two lowest bits, which distinguish a heap object from a small integer. 57// Tag information for HeapObject. 58const int kHeapObjectTag = 1; 59const int kWeakHeapObjectTag = 3; 60const int kHeapObjectTagSize = 2; 61const intptr t kHeapObjectTagMask = 1 << kHeapObjectTagSize - 1; 62const intptr t kHeapObjectReferenceTagMask = 1 << kHeapObjectTagSize - 1 ; 63 64// Tag information for fowarding pointers stored in object headers. 65// 0b00 at the lowest 2 bits in the header indicates that the map word is a 66// forwarding pointer. 67const int kForwardingTag = 0; 68const int kForwardingTagSize = 2; 69const intptr t kForwardingTagMask = 1 << kForwardingTagSize - 1; 70 71// Tag information for Smi. 72const int kSmiTag = 0; 73const int kSmiTagSize = 1; 74const intptr t kSmiTagMask = 1 << kSmiTagSize - 1; The guard regions cover the worst case of a typed array access with a corrupted index. 290// Size of the guard regions surrounding the sandbox. This assumes a worst-case 291// scenario of a 32-bit unsigned index used to access an array of 64-bit values 292// with an additional 32GB bounded size offset. In particular, accesses to 293// TypedArrays are effectively computed as 294// entry pointer = array- base + array- offset + index array- element size . 295// See also https://crbug.com/40070746 for more details. 296constexpr size t kSandboxGuardRegionSize = 297 32ULL GB + kMaxSafeBufferSizeForSandbox + 1 ; V8 also asks the kernel to keep the sandbox out of core dumps. 39// Exclude a large virtual reservation from core dumps. Without this, the 40// kernel walks the multi-TB sandbox reservation when writing a coredump, 41// which can take minutes even though almost none of it is resident. See 42// core 5 on MADV DONTDUMP. No-op on non-Linux platforms; other OSes either 43// already skip PROT NONE mappings or don't expose an equivalent knob. 44void ExcludeReservationFromCoreDump Address base, size t size { 45 if V8 OS LINUX 46 // Best-effort: ignore failures e.g. old kernels without MADV DONTDUMP . 47 madvise reinterpret cast