{"slug": "a-27-byte-telemetry-response-nobody-reads-freezes-claude-desktop-every-two-days", "title": "A 27-byte telemetry response nobody reads freezes Claude Desktop every two days", "summary": "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.", "body_md": "# A 27-byte telemetry response nobody reads freezes Claude Desktop every two days\n\n**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\".\n\nA 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.\n\n## Two months of monkey-patching[#](#two-months-of-monkey-patching)\n\nOne fine day I noticed that while claude was building something in the background, my whole machine was lagging like hell.\nI blamed the build and ran `uptime`.\nLoad average 46, and `uptime` itself took 20 seconds to start.\n`cat /proc/pressure/io`:\n\n```\nfull avg10=85.30 avg60=74.12 avg300=28.47 total=2187342911\n```\n\nWHAT. THE. FUCK.\n\nSince when can a build saturate an NVMe like this?\nSwitched to claude.\nOnly the skeleton was there.\nMy first thought was that the llm had done something stupid and killed itself.\nBut something was still thrashing the disk.\nIt turned out to be `systemd-coredump` saving the core and killing the disk.\nThe renderer had crashed => `SIGILL` => a core dump: the kernel writes the whole memory of the process to a file.\n\nSince it's Chromium, the dump is small: only one and a half terabytes.\nMore about why this chungus is so big a bit later.\nAnd the dump is basically RPC, because the kernel starts `systemd-coredump` and pipes the memory to it.\nA pipe can't seek, so the pages nobody ever touched can't be skipped and go out as real zeros.\nI 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.\nTeamwork.\n\nOk.\nBut why did the renderer crash in the first place?\nAll the app's `main.log` had was \"Main webview is unresponsive, will kill and reload\" every 20 to 40 seconds.\nObviously nothing was reloaded.\nSo I started patching Claude Desktop bit by bit.\nI capped core dumps at 4 GiB.\nThe 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.\n\n11 days later the app did kill its hung renderer, and Chromium left me this:\n\n```\nFATAL:content/child/child_thread_impl.cc:380] Crashing because hung\n```\n\nCool. Good logs.\n\nSame story — dead renderer, `SIGILL`, core.\nCapped this time, it had one stack frame in it and still hung the laptop for a couple of minutes.\nIt 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.\nLet's wait for the next postcard.\n\n13 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\":\n\n```\n[hang-stack] main webview renderer gone: { reason: 'clean-exit', exitCode: 0 }\n```\n\nThe debugger had nothing to print, because nothing hung. It's just an exit, Chromium simply decided to quit.\n\nThe 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.\n\nI 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.\n\n## The core dump[#](#the-core-dump)\n\n8 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.\n\nThis time I was lucky: the renderer was hung and still alive, not dead like all the previous times.\nI ran `gcore` by hand and dumped the renderer and the browser process, which is Chromium's name for the app's main process.\n\nThis 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.\n\nNow, 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.\n\n## Address space details (Chrome 152)\n\n1376 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.\n\nThese are the biggest reservations in the renderer's dump.\n\n| Size | What it is | \n|---|---|\n| 64 GiB | guard region in front of the sandbox | \n| 1 TiB | the V8 sandbox | \n| 288 GiB | guard region after the sandbox | \n| 32 GiB | two pools of PartitionAlloc, Chromium's allocator | \n| 16 GiB | Oilpan, the garbage-collected heap for C++ objects | \n| 4 GiB | the bottom of the address space, so that a small integer used as a pointer faults | \n\nA pointer to a buffer inside the sandbox is stored as an offset from the sandbox base.\n\n```\n243// Everywhere else use a 1TB sandbox.\n244constexpr size_t kSandboxSizeLog2 = 40;  // 1 TB\n245#endif  // V8_TARGET_OS_ANDROID\n246constexpr size_t kSandboxSize = 1ULL << kSandboxSizeLog2;\n247\n248// Required alignment of the sandbox. For simplicity, we require the\n249// size of the guard regions to be a multiple of this, so that this specifies\n250// the alignment of the sandbox including and excluding surrounding guard\n251// regions. The alignment requirement is due to the pointer compression cage\n252// being located at the start of the sandbox.\n253constexpr size_t kSandboxAlignment = kPtrComprCageBaseAlignment;\n254\n255// Sandboxed pointers are stored inside the heap as offset from the sandbox\n256// base shifted to the left. This way, it is guaranteed that the offset is\n257// smaller than the sandbox size after shifting it to the right again. This\n258// constant specifies the shift amount.\n259constexpr uint64_t kSandboxedPointerShift = 64 - kSandboxSizeLog2;\n```\n\nThe 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.\n\n```\n57// Tag information for HeapObject.\n58const int kHeapObjectTag = 1;\n59const int kWeakHeapObjectTag = 3;\n60const int kHeapObjectTagSize = 2;\n61const intptr_t kHeapObjectTagMask = (1 << kHeapObjectTagSize) - 1;\n62const intptr_t kHeapObjectReferenceTagMask = 1 << (kHeapObjectTagSize - 1);\n63\n64// Tag information for fowarding pointers stored in object headers.\n65// 0b00 at the lowest 2 bits in the header indicates that the map word is a\n66// forwarding pointer.\n67const int kForwardingTag = 0;\n68const int kForwardingTagSize = 2;\n69const intptr_t kForwardingTagMask = (1 << kForwardingTagSize) - 1;\n70\n71// Tag information for Smi.\n72const int kSmiTag = 0;\n73const int kSmiTagSize = 1;\n74const intptr_t kSmiTagMask = (1 << kSmiTagSize) - 1;\n```\n\nThe guard regions cover the worst case of a typed array access with a corrupted index.\n\n```\n290// Size of the guard regions surrounding the sandbox. This assumes a worst-case\n291// scenario of a 32-bit unsigned index used to access an array of 64-bit values\n292// with an additional 32GB (bounded size) offset. In particular, accesses to\n293// TypedArrays are effectively computed as\n294// `entry_pointer = array->base + array->offset + index * array->element_size`.\n295// See also https://crbug.com/40070746 for more details.\n296constexpr size_t kSandboxGuardRegionSize =\n297    32ULL * GB + (kMaxSafeBufferSizeForSandbox + 1);\n```\n\nV8 also asks the kernel to keep the sandbox out of core dumps.\n\n```\n39// Exclude a large virtual reservation from core dumps. Without this, the\n40// kernel walks the multi-TB sandbox reservation when writing a coredump,\n41// which can take minutes even though almost none of it is resident. See\n42// core(5) on MADV_DONTDUMP. No-op on non-Linux platforms; other OSes either\n43// already skip PROT_NONE mappings or don't expose an equivalent knob.\n44void ExcludeReservationFromCoreDump(Address base, size_t size) {\n45#if V8_OS_LINUX\n46  // Best-effort: ignore failures (e.g. old kernels without MADV_DONTDUMP).\n47  madvise(reinterpret_cast<void*>(base), size, MADV_DONTDUMP);\n48#endif\n49}\n```\n\nIn a renderer that call is denied, because the renderer's seccomp policy allows only six kinds of `madvise`.\n\n```\n243  if (sysno == __NR_madvise) {\n244    // Only allow MADV_DONTNEED, MADV_WILLNEED, MADV_RANDOM, MADV_REMOVE,\n245    // MADV_NORMAL, and MADV_FREE.\n246    const Arg<int> advice(2);\n247    return If(AnyOf(advice == MADV_DONTNEED, advice == MADV_WILLNEED,\n248                    advice == MADV_RANDOM, advice == MADV_REMOVE,\n249                    advice == MADV_NORMAL\n250#if defined(MADV_FREE)\n251                    // MADV_FREE was introduced in Linux 4.5 and started being\n252                    // defined in glibc 2.24.\n253                    ,\n254                    advice == MADV_FREE\n255#endif\n256                    ),\n257              Allow())\n258        .Else(Error(EPERM));\n259  }\n```\n\nIn `/proc/<pid>/smaps` of the running app the browser process has the `dd` (don't dump) flag on its sandbox, and the renderers don't.\n\nA 1.5 TiB file is not something you grep.\nOne `rg` over the two dumps took almost 8 minutes, nearly all of it kernel time spent reading the holes.\nSo I quickly vibe-coded a Rust parser that asks the filesystem where the data is, with `lseek(SEEK_DATA)` and `lseek(SEEK_HOLE)`, and reads only that.\nA search over the renderer's dump now takes 0.7 seconds (`mmap`, `memchr`, nothing fancy enough to show the code).\n\n## Reading the dump[#](#reading-the-dump)\n\nThe first surprise: a dump is a snapshot of memory, but I could recover the flow of events from it, as if I had several snapshots from different moments.\n`free` in C only marks the region as freed and nobody zeroes it (and the compiler will try to optimize the zeroing away anyway), so everything freed is still visible in the dump.\n\nMojo (not the lang), Chromium's IPC, copies its read buffer when the buffer grows or shifts, and frees the old one without wiping it. The dump had six identical copies of one chunk of the IPC stream, the one I needed.\n\n## Freed memory details (Chrome 152)\n\nChromium allocates through PartitionAlloc.\nOn free the slot goes to a freelist: the first 16 bytes get an encoded pointer to the next free slot, the rest of that cache line is poisoned with `0xbadbad00`, and everything after it stays as it was.\n\n```\n593  // The issue is that we detect the UaF far away from the place where it\n594  // happens. As a consequence, we should try to make incorrect code crash as\n595  // early as possible. Poisoning memory at free() time works for UaF, but it\n596  // was seen in the past to incur a high performance cost.\n597  //\n598  // Here, only poison the current cacheline, which we are touching anyway.\n```\n\nIn a Mojo channel, `Reserve` allocates a bigger read buffer and copies the occupied bytes into it.\n\n``` js\n943  char* Reserve(size_t num_bytes) {\n944    const auto new_size = base::CheckAdd(num_bytes, size_);\n945    if (!new_size.IsValid()) {\n946      return nullptr;\n947    }\n948    if (num_occupied_bytes_ + num_bytes > size_) {\n949      size_ = std::max(static_cast<size_t>(size_ * kGrowthFactor),\n950                       num_occupied_bytes_ + num_bytes);\n951      AlignedBuffer new_data = MakeAlignedBuffer(size_);\n952      new_data.copy_prefix_from(data_.first(num_occupied_bytes_));\n953      data_ = std::move(new_data);\n954    }\n955\n956    return data_.subspan(num_occupied_bytes_).data();\n957  }\n```\n\n`Realign` moves them to the front of a new buffer.\n\n```\n1001  void Realign() {\n1002    size_t num_bytes = num_occupied_bytes();\n1003    auto new_data = MakeAlignedBuffer(data_.size());\n1004    new_data.copy_prefix_from(data_.subspan(num_discarded_bytes_, num_bytes));\n1005    data_ = std::move(new_data);\n1006    num_discarded_bytes_ = 0;\n1007    num_occupied_bytes_ = num_bytes;\n1008  }\n```\n\nThe garbage was actual gold.\nThe renderer's main thread was hung, waiting for an answer from the browser process.\nMy first theory was that the browser process had never answered.\nIt had, because the answer was in those six copies of the read buffer, so the renderer had received it.\nThe next theory was that Chromium's IPC had lost the wakeup somehow.\nIt hadn't.\nThe answer was stuck behind another message, and that one had come without the file descriptor (fd) it was supposed to carry.\nAt the same moment the journal got 17241 `TransferBuffer::Initialize() failed` lines within 8 seconds.\nBoth meant that the renderer had run out of fds.\n\n## A file descriptor leak[#](#a-file-descriptor-leak)\n\nNearly all of the renderer's descriptors were 2 MiB shared-memory regions, the kind Chromium uses to hand a response body to the renderer. The dump had 942 such regions, and 938 of them contained only this:\n\n```\n{\"refresh_after_seconds\":0}\n```\n\nThat's 27 bytes.\n\nWho kept those shared-memory regions alive can be read from the dump too, because Chromium's objects say what they are.\nC++ objects are found by their vtable pointer, and scanning the dump for the vptr of `blink::Response` found every `Response` in the renderer.\nFrom each `Response` I went to its JS wrapper, and from the wrapper to whoever refers to it, by searching for the 32-bit compressed V8 pointer.\nIn V8, JS objects with the same layout share one map, also called a hidden class, and decoding it gave the owner's property names.\nThe owner turned out to be Datadog's browser SDK, which the app bundles, more on that below.\n\n### The presence request[#](#the-presence-request)\n\nThe Datadog objects that held those responses also held the requests behind them. The app's Code tab sends presence telemetry, reporting to the server that this client is active in a Code session. When the window is hidden or idle, it sends a clear:\n\n```\nPOST /v1/code/channels/<id>/client/presence\n\n{\"client_id\":\"...\",\"clear\":true}\n```\n\nThe request goes out with `keepalive: true`, which lets it outlive the page, and the app never reads the response:\n\n``` js\nfetch(url, {method:\"POST\", headers, body, keepalive:true}).catch(()=>{})\n```\n\nThe app says \"the user is not here\", gets 27 bytes of \"OK\" back, and doesn't await them, because nobody needs them.\nNormal fire-and-forget thing to do.\nThe regular presence request does read its body with `await r.json()`; only the clear doesn't.\nA fresh renderer collected 45 of these 2 MiB shared-memory regions an hour, and the old one had its 942 after 35 hours.\n\n### no-store bodies[#](#no-store-bodies)\n\nChromium hands a response body to the renderer through a Mojo data pipe, whatever the size of the body. Each pipe is a 2 MiB shared-memory region and one fd in the renderer.\n\nBlink wraps a fetch body in `BufferingBytesConsumer`, which drains the pipe into memory shortly after the body arrives.\nIt skips that when `fetch()` was given an `integrity` hash to check the body against, or the response has `Cache-Control: no-store`:\n\n```\n818void FetchManager::Loader::DidStartLoadingResponseBody(BytesConsumer& body) {\n819  if (GetFetchRequestData()->Integrity().empty() &&\n820      !response_has_no_store_header_) {\n821    // BufferingBytesConsumer reads chunks from |bytes_consumer| as soon as\n822    // they get available to relieve backpressure.  Buffering starts after\n823    // a short delay, however, to allow the Response to be drained; e.g.\n824    // when the Response is passed to FetchEvent.respondWith(), etc.\n825    //\n826    // https://fetch.spec.whatwg.org/#fetching\n827    // The user agent should ignore the suspension request if the ongoing\n828    // fetch is updating the response in the HTTP cache for the request.\n829    place_holder_body_->Update(BufferingBytesConsumer::CreateWithDelay(\n830        &body, GetExecutionContext()->GetTaskRunner(TaskType::kNetworking)));\n831  } else {\n832    place_holder_body_->Update(&body);\n833  }\n834  place_holder_body_ = nullptr;\n835}\n```\n\nSo an unread `no-store` body stays in its pipe, and the pipe keeps its fd, until it is read or cancelled.\nThe replies to the presence clear took that path.\nIn the hung renderer all response bodies but one had skipped `BufferingBytesConsumer`.\n\nAs far as I understand, this is done so that unread data doesn't pile up in memory. And it works great, there is no memory leak. The browser freezes dead instead, which is a much better outcome.\n\n### The Datadog theory[#](#the-datadog-theory)\n\nClaude Desktop bundles Datadog's browser SDK, which records the requests the app makes. That's expected, no discovery there.\n\nIn the core dump, Datadog request objects held 801 replies to the presence clear, all in one JS Set.\nThe SDK keeps every completed fetch in a Set, together with its `Response`, until a resource timing entry matches it or the Set passes 1000:\n\n```\n11// Maximum number of requests to keep in the registry. Requests should be removed quite quickly in\n12// general, this is just a safety limit to avoid memory leaks in case of a bug.\n13export const MAX_REQUESTS = 1000\n14\n15export function createRequestRegistry(lifeCycle: LifeCycle): RequestRegistry {\n16  const requests = new Set<RequestCompleteEvent>()\n17\n18  const subscription = lifeCycle.subscribe(LifeCycleEventType.REQUEST_COMPLETED, (request) => {\n19    requests.add(request)\n20\n21    if (requests.size > MAX_REQUESTS) {\n22      requests.delete(requests.values().next().value!)\n23    }\n24  })\n```\n\nAn unread `no-store` response never gets a timing entry, so it sits in the Set until newer requests push it out.\n\nThat's it.\nI thought: Datadog holds the `Response` objects, so the GC can't free them.\nI wanted to send a PR to Datadog, but only after I finished writing the post, cause in my mind they were not the main villain here.\n\nDuring post proofreading an LLM flagged a numbers mismatch.\n938 regions held the reply, and only 801 of them had a `Response` in Datadog's Set.\nSo I ran the repro (it's further down) without keeping the `Response` objects, with a `FinalizationRegistry` to count the collected ones.\nAll 800 `Response` objects were collected, and 836 descriptors were still open: the renderer's own few dozen plus one per response.\nChromium doesn't close the data pipe, and with it the descriptor, when the `Response` is collected.\nSo Datadog has nothing to do with it.\n\n### The descriptor limit[#](#the-descriptor-limit)\n\nThe renderer's soft `RLIMIT_NOFILE` is 1024, while the browser process raises its own to 524288.\nAbout 1000 descriptors of unread replies, plus the ones the renderer holds anyway, exhaust it.\n\nWhen the process is out of descriptors, the kernel can't deliver a descriptor passed over the IPC socket. On Chrome 152 a release build of Mojo doesn't check for that, so the message arrives without its descriptor. The channel stands and waits for the descriptor, and the descriptor never comes. Every later message on that channel is stuck behind it.\n\n## Mojo details (Chrome 152)\n\nWhen the descriptor can't be delivered, `recvmsg` sets `MSG_CTRUNC`, and Mojo only `DCHECK` s that:\n\n```\n112  ssize_t result =\n113      HANDLE_EINTR(recvmsg(socket, &msg, block ? 0 : MSG_DONTWAIT));\n114  if (result < 0) {\n115    return result;\n116  }\n117\n118  if (msg.msg_controllen == 0) {\n119    return result;\n120  }\n121\n122  DCHECK(!(msg.msg_flags & MSG_CTRUNC));\n```\n\n`TryDispatchMessage` gets fewer handles than the header promises and returns `kMissingHandles`.\n\n```\n1153    std::vector<PlatformHandle> handles;\n1154    if (num_handles > 0) {\n1155      if (handle_policy_ == HandlePolicy::kRejectHandles) {\n1156        return DispatchResult::kError;\n1157      }\n1158\n1159      if (received_handles) {\n1160        handles = std::move(*received_handles);\n1161      } else if (!GetReadPlatformHandlesForIpcz(num_handles, handles)) {\n1162        return DispatchResult::kError;\n1163      }\n1164\n1165      if (handles.size() < num_handles) {\n1166        return DispatchResult::kMissingHandles;\n1167      }\n1168    }\n```\n\n`OnReadComplete` then stops dispatching and leaves the message in the read buffer.\n\n```\n1100    DispatchResult result = TryDispatchMessage(\n1101        UNSAFE_TODO(base::span(base::unchecked, read_buffer_->occupied_bytes(),\n1102                               read_buffer_->num_occupied_bytes())),\n1103        next_read_size_hint);\n1104    if (result == DispatchResult::kOK) {\n1105      read_buffer_->Discard(*next_read_size_hint);\n1106      *next_read_size_hint = 0;\n1107\n1108      if (!DispatchDelayedMessages()) {\n1109        return false;\n1110      }\n1111    } else if (result == DispatchResult::kNotEnoughData) {\n1112      return true;\n1113    } else if (result == DispatchResult::kMissingHandles) {\n1114      break;\n1115    } else if (result == DispatchResult::kError) {\n1116      return false;\n1117    }\n1118  }\n1119  return true;\n```\n\nThe main thread was blocked in a synchronous `BlobRegistry.Register` call, issued by an IndexedDB put, and the reply to it sat behind the stuck message.\n\nSo once a thousand of these replies have piled up, everything freezes dead.\n\n## A repro without claude[#](#a-repro-without-claude)\n\nA page sends 1100 POSTs one after another and keeps every `Response` in an array, like Datadog does, without reading the body.\nThe server answers with a tiny `no-store` body.\n\n`index.html`:\n\n```\n<!doctype html>\n<title>start</title>\n<pre id=\"log\"></pre>\n<script>\n// Open as /?keepalive to send the requests with keepalive: true.\nconst keepalive = location.search === \"?keepalive\";\nconst kept = [];\n(async () => {\n  for (let i = 1; i <= 1100; i++) {\n    const r = await fetch(\"/x\", { method: \"POST\", body: \"{}\", keepalive });\n    kept.push(r);\n    if (i % 100 === 0) {\n      document.title = \"fetched \" + i;\n      log.textContent += \"fetched \" + i + \"\\n\";\n    }\n  }\n  document.title = \"done\";\n  log.textContent += \"done\\n\";\n})();\n</script>\n```\n\n## `server.py`\n\n``` python\nimport http.server, pathlib, sys\n\nPAGE = (pathlib.Path(__file__).parent / \"index.html\").read_bytes()\n\nclass H(http.server.BaseHTTPRequestHandler):\n    def do_GET(self):\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"text/html\")\n        self.end_headers()\n        self.wfile.write(PAGE)\n\n    def do_POST(self):\n        self.rfile.read(int(self.headers.get(\"Content-Length\", 0)))\n        body = b'{\"ok\":true}'\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"application/json\")\n        self.send_header(\"Cache-Control\", \"no-store\")\n        self.send_header(\"Content-Length\", str(len(body)))\n        self.end_headers()\n        self.wfile.write(body)\n\n    def log_message(self, *a):\n        pass\n\nhttp.server.ThreadingHTTPServer((\"127.0.0.1\", int(sys.argv[1]) if len(sys.argv) > 1 else 8000), H).serve_forever()\n```\n\nRun `python3 server.py 8000`, then `ulimit -Sn 1024`, and start the browser from that shell.\nThe app's request has `keepalive: true`, so every browser ran with and without it.\nThe results on Linux with a soft `RLIMIT_NOFILE` of 1024:\n\n|  | without `keepalive` | with `keepalive` | \n|---|---|---|\n| Electron 44.9.3 (Chrome 152) | stops at \"fetched 900\", the page's JS still runs | the page's JS stops | \n| Electron 45.2.3 (Chrome 156) | stops at \"fetched 900\", the page's JS still runs | the renderer exits after \"fetched 900\": `clean-exit` , code 0 | \n| Chrome 156 headless | stops at \"fetched 900\", the next `fetch()` never settles | the renderer process disappears | \n| Electron 46 alpha (Chrome 159) | the page's JS still runs, the next `fetch()` never settles | the renderer exits: `clean-exit` , code 0 | \n| Firefox 159 headless | all 1100 done, descriptors stay flat | all 1100 done, one memfd per `Response` | \n\nWhere the page stops, the renderer sits at 1020 to 1024 descriptors.\nWithout `Cache-Control: no-store` nothing accumulates: the bodies are buffered and the pipes are closed within 250 ms.\nOn Electron 44.9.3 the page stops at the same place without `kept.push(r)`.\n\nI never reproduced the clean exits the app had on Chrome 152, where the repro only hangs.\n\n## What Chromium says[#](#what-chromium-says)\n\nChromium has a bug for this, [378822137](https://issues.chromium.org/issues/378822137), \"fetch() API will cause FD leak when request no-empty response with no-store\".\nIt's Assigned, P3.\nIts owner's answer in comment #2:\n\nIf you can read the Response, but choose not to, I think this is working as intended. The browser cannot know that you won't read the response in future, so it has to hold on to it.\n\nFine, hold on to it.\nBut any sane API would fail the next `fetch()` with an error when the descriptors run out.\nInstead the renderer freezes dead, and I get to debug it for a month.\n\nI would just buffer small bodies, `no-store` or not.\nShared memory lives in RAM anyway, so there's no real difference in memory between keeping the response on the heap and keeping it in a 2 MiB shared-memory region.\nI guess it's easier to calculate limits based on 2 MiB regions, but I'm not ready to dive even deeper into Chromium (yet).\nComment #16 on that bug proposes buffering too, for completely received bodies under about 512 KB.\nIn Firefox an unread body takes no descriptor at all, unless the request has `keepalive`.\nWith `keepalive` Firefox holds one memfd per `Response` and still finishes the repro, because it raises its own soft limit to 65536 and ignores `ulimit -Sn 1024`.\n\nI'm not the first one to step on this. The bug has been open since 2024, and its reporter wrote back in comment #8: \"Many web app use bare fetch() to request no-empty response with no-store but never read it.\"\n\n## Newer Chrome[#](#newer-chrome)\n\nThe leak is intended, but the freeze at exhaustion was a bug, and Chromium fixed it in Chrome 154, in the commit [Reland \"Make Mojo fail more predictably under FD exhaustion\"](https://github.com/chromium/chromium/commit/0ce1bca2693a03bcee73f839c3e63b38ff5693f9) for [crbug 342779913](https://issues.chromium.org/issues/342779913).\n\n## The fix: a missing descriptor now changes the return value\n\n```\n112  ssize_t result =\n113      HANDLE_EINTR(recvmsg(socket, &msg, block ? 0 : MSG_DONTWAIT));\n114  if (result < 0) {\n115    return result;\n116  }\n117\n118  // Undocumented behavior: the MSG_CTRUNC flag can occur under FD exhaustion.\n119  const bool missing_fds = msg.msg_flags & MSG_CTRUNC;\n120  if (msg.msg_controllen != 0) {\n121    descriptors->clear();\n122    for (cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); cmsg;\n123         cmsg = UNSAFE_TODO(CMSG_NXTHDR(&msg, cmsg))) {\n124      if (cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_RIGHTS) {\n125        size_t payload_length = cmsg->cmsg_len - CMSG_LEN(0);\n126        DCHECK_EQ(payload_length % sizeof(int), 0u);\n127        size_t num_fds = payload_length / sizeof(int);\n128        const int* fds = reinterpret_cast<int*>(UNSAFE_TODO(CMSG_DATA(cmsg)));\n129        for (size_t i = 0; i < num_fds; ++i) {\n130          base::ScopedFD fd(UNSAFE_TODO(fds[i]));\n131          DCHECK(fd.is_valid());\n132          // If some FDs are missing, this loop is necessary to release the ones\n133          // that were successfully received.\n134          if (!missing_fds) {\n135            descriptors->emplace_back(std::move(fd));\n136          }\n137        }\n138      }\n139    }\n140  }\n141\n142  return missing_fds ? 0 : result;\n143}\n```\n\nClaude Desktop is still on Chrome 152, without the fix.\nWith the fix and `keepalive` requests, the renderer exits with code 0, and the page still gets no error.\n\nThat's a bit better than the whole renderer freezing, because a hang is ~~pure cringe~~ an interesting design decision.\nI'd still prefer an exception, as for any other error: the renderer doesn't die when your internet connection is bad.\n\n## Two reports[#](#two-reports)\n\nTo Chromium I filed [582417463](https://issues.chromium.org/issues/582417463), asking for `fetch()` to reject with a `TypeError` when it can't get an fd.\nChromium closed it the same day as a duplicate of 378822137:\n\n378822137 is still open an has an active idea in it's backlog to implement. Please don't open duplicate bugs.\n\nFair enough, though mine asked for a different thing.\n\nThe fix in the app is one line: `.then(r => r.body?.cancel())` after the fetch, or `navigator.sendBeacon` instead of it.\nTo Anthropic I filed [anthropics/claude-code#107887](https://github.com/anthropics/claude-code/issues/107887), \"[BUG] Claude Desktop on Linux freezes after a day or two: the renderer runs out of file descriptors\".\nA bot closed the issue with the label `invalid`, and no human replied:\n\nClosing for now — this doesn't appear to be about Claude Code.\n\nThe request comes from the app's Code tab, which is Claude Code. A bot says it's not about Claude Code, and Chromium says it's working as intended. Seems like nobody cares. Maybe after this post somebody sees the issue and fixes that one line.\n\nUntil then I fixed it on my side, with a patch on top of the official build. The app loads its frontend from claude.ai at run time, so my build keeps a debugger attached to the window, catches the script that sends the presence clear, and adds that one ✨ load-bearing ✨ line to it:\n\n``` js\nfetch(url, {method:\"POST\", headers, body, keepalive:true}).then(r=>r.body?.cancel()).catch(()=>{})\n```\n\nWith the body cancelled, the Electron 44.9.3 repro finishes all 1100 requests at 38 descriptors, and without it the page stops. The patch works, but none of this is normal.\n\nI think this bug is fairly common, but people are just used to low-quality software and to restarting an app that reliably freezes every couple of days. I'm not.", "url": "https://wpnews.pro/news/a-27-byte-telemetry-response-nobody-reads-freezes-claude-desktop-every-two-days", "canonical_source": "https://blog.dfb.sh/blog/claude-desktop-freeze/", "published_at": "2026-11-13 14:04:15+00:00", "updated_at": "2026-11-13 14:22:27.544455+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-products"], "entities": ["Claude Desktop", "Anthropic", "Chromium", "Electron", "systemd-coredump", "ChatGPT"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/a-27-byte-telemetry-response-nobody-reads-freezes-claude-desktop-every-two-days", "markdown": "https://wpnews.pro/news/a-27-byte-telemetry-response-nobody-reads-freezes-claude-desktop-every-two-days.md", "text": "https://wpnews.pro/news/a-27-byte-telemetry-response-nobody-reads-freezes-claude-desktop-every-two-days.txt", "jsonld": "https://wpnews.pro/news/a-27-byte-telemetry-response-nobody-reads-freezes-claude-desktop-every-two-days.jsonld"}}