# A 27-byte telemetry response nobody reads freezes Claude Desktop every two days

> Source: <https://blog.dfb.sh/blog/claude-desktop-freeze/>
> Published: 2026-11-13 14:04:15+00:00

# 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<void*>(base), size, MADV_DONTDUMP);
48#endif
49}
```

In a renderer that call is denied, because the renderer's seccomp policy allows only six kinds of `madvise`.

```
243  if (sysno == __NR_madvise) {
244    // Only allow MADV_DONTNEED, MADV_WILLNEED, MADV_RANDOM, MADV_REMOVE,
245    // MADV_NORMAL, and MADV_FREE.
246    const Arg<int> advice(2);
247    return If(AnyOf(advice == MADV_DONTNEED, advice == MADV_WILLNEED,
248                    advice == MADV_RANDOM, advice == MADV_REMOVE,
249                    advice == MADV_NORMAL
250#if defined(MADV_FREE)
251                    // MADV_FREE was introduced in Linux 4.5 and started being
252                    // defined in glibc 2.24.
253                    ,
254                    advice == MADV_FREE
255#endif
256                    ),
257              Allow())
258        .Else(Error(EPERM));
259  }
```

In `/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.

A 1.5 TiB file is not something you grep.
One `rg` over the two dumps took almost 8 minutes, nearly all of it kernel time spent reading the holes.
So 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.
A search over the renderer's dump now takes 0.7 seconds (`mmap`, `memchr`, nothing fancy enough to show the code).

## Reading the dump[#](#reading-the-dump)

The 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.
`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.

Mojo (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.

## Freed memory details (Chrome 152)

Chromium allocates through PartitionAlloc.
On 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.

```
593  // The issue is that we detect the UaF far away from the place where it
594  // happens. As a consequence, we should try to make incorrect code crash as
595  // early as possible. Poisoning memory at free() time works for UaF, but it
596  // was seen in the past to incur a high performance cost.
597  //
598  // Here, only poison the current cacheline, which we are touching anyway.
```

In a Mojo channel, `Reserve` allocates a bigger read buffer and copies the occupied bytes into it.

``` js
943  char* Reserve(size_t num_bytes) {
944    const auto new_size = base::CheckAdd(num_bytes, size_);
945    if (!new_size.IsValid()) {
946      return nullptr;
947    }
948    if (num_occupied_bytes_ + num_bytes > size_) {
949      size_ = std::max(static_cast<size_t>(size_ * kGrowthFactor),
950                       num_occupied_bytes_ + num_bytes);
951      AlignedBuffer new_data = MakeAlignedBuffer(size_);
952      new_data.copy_prefix_from(data_.first(num_occupied_bytes_));
953      data_ = std::move(new_data);
954    }
955
956    return data_.subspan(num_occupied_bytes_).data();
957  }
```

`Realign` moves them to the front of a new buffer.

```
1001  void Realign() {
1002    size_t num_bytes = num_occupied_bytes();
1003    auto new_data = MakeAlignedBuffer(data_.size());
1004    new_data.copy_prefix_from(data_.subspan(num_discarded_bytes_, num_bytes));
1005    data_ = std::move(new_data);
1006    num_discarded_bytes_ = 0;
1007    num_occupied_bytes_ = num_bytes;
1008  }
```

The garbage was actual gold.
The renderer's main thread was hung, waiting for an answer from the browser process.
My first theory was that the browser process had never answered.
It had, because the answer was in those six copies of the read buffer, so the renderer had received it.
The next theory was that Chromium's IPC had lost the wakeup somehow.
It hadn't.
The answer was stuck behind another message, and that one had come without the file descriptor (fd) it was supposed to carry.
At the same moment the journal got 17241 `TransferBuffer::Initialize() failed` lines within 8 seconds.
Both meant that the renderer had run out of fds.

## A file descriptor leak[#](#a-file-descriptor-leak)

Nearly 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:

```
{"refresh_after_seconds":0}
```

That's 27 bytes.

Who kept those shared-memory regions alive can be read from the dump too, because Chromium's objects say what they are.
C++ objects are found by their vtable pointer, and scanning the dump for the vptr of `blink::Response` found every `Response` in the renderer.
From 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.
In V8, JS objects with the same layout share one map, also called a hidden class, and decoding it gave the owner's property names.
The owner turned out to be Datadog's browser SDK, which the app bundles, more on that below.

### The presence request[#](#the-presence-request)

The 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:

```
POST /v1/code/channels/<id>/client/presence

{"client_id":"...","clear":true}
```

The request goes out with `keepalive: true`, which lets it outlive the page, and the app never reads the response:

``` js
fetch(url, {method:"POST", headers, body, keepalive:true}).catch(()=>{})
```

The app says "the user is not here", gets 27 bytes of "OK" back, and doesn't await them, because nobody needs them.
Normal fire-and-forget thing to do.
The regular presence request does read its body with `await r.json()`; only the clear doesn't.
A fresh renderer collected 45 of these 2 MiB shared-memory regions an hour, and the old one had its 942 after 35 hours.

### no-store bodies[#](#no-store-bodies)

Chromium 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.

Blink wraps a fetch body in `BufferingBytesConsumer`, which drains the pipe into memory shortly after the body arrives.
It skips that when `fetch()` was given an `integrity` hash to check the body against, or the response has `Cache-Control: no-store`:

```
818void FetchManager::Loader::DidStartLoadingResponseBody(BytesConsumer& body) {
819  if (GetFetchRequestData()->Integrity().empty() &&
820      !response_has_no_store_header_) {
821    // BufferingBytesConsumer reads chunks from |bytes_consumer| as soon as
822    // they get available to relieve backpressure.  Buffering starts after
823    // a short delay, however, to allow the Response to be drained; e.g.
824    // when the Response is passed to FetchEvent.respondWith(), etc.
825    //
826    // https://fetch.spec.whatwg.org/#fetching
827    // The user agent should ignore the suspension request if the ongoing
828    // fetch is updating the response in the HTTP cache for the request.
829    place_holder_body_->Update(BufferingBytesConsumer::CreateWithDelay(
830        &body, GetExecutionContext()->GetTaskRunner(TaskType::kNetworking)));
831  } else {
832    place_holder_body_->Update(&body);
833  }
834  place_holder_body_ = nullptr;
835}
```

So an unread `no-store` body stays in its pipe, and the pipe keeps its fd, until it is read or cancelled.
The replies to the presence clear took that path.
In the hung renderer all response bodies but one had skipped `BufferingBytesConsumer`.

As 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.

### The Datadog theory[#](#the-datadog-theory)

Claude Desktop bundles Datadog's browser SDK, which records the requests the app makes. That's expected, no discovery there.

In the core dump, Datadog request objects held 801 replies to the presence clear, all in one JS Set.
The SDK keeps every completed fetch in a Set, together with its `Response`, until a resource timing entry matches it or the Set passes 1000:

```
11// Maximum number of requests to keep in the registry. Requests should be removed quite quickly in
12// general, this is just a safety limit to avoid memory leaks in case of a bug.
13export const MAX_REQUESTS = 1000
14
15export function createRequestRegistry(lifeCycle: LifeCycle): RequestRegistry {
16  const requests = new Set<RequestCompleteEvent>()
17
18  const subscription = lifeCycle.subscribe(LifeCycleEventType.REQUEST_COMPLETED, (request) => {
19    requests.add(request)
20
21    if (requests.size > MAX_REQUESTS) {
22      requests.delete(requests.values().next().value!)
23    }
24  })
```

An unread `no-store` response never gets a timing entry, so it sits in the Set until newer requests push it out.

That's it.
I thought: Datadog holds the `Response` objects, so the GC can't free them.
I 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.

During post proofreading an LLM flagged a numbers mismatch.
938 regions held the reply, and only 801 of them had a `Response` in Datadog's Set.
So I ran the repro (it's further down) without keeping the `Response` objects, with a `FinalizationRegistry` to count the collected ones.
All 800 `Response` objects were collected, and 836 descriptors were still open: the renderer's own few dozen plus one per response.
Chromium doesn't close the data pipe, and with it the descriptor, when the `Response` is collected.
So Datadog has nothing to do with it.

### The descriptor limit[#](#the-descriptor-limit)

The renderer's soft `RLIMIT_NOFILE` is 1024, while the browser process raises its own to 524288.
About 1000 descriptors of unread replies, plus the ones the renderer holds anyway, exhaust it.

When 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.

## Mojo details (Chrome 152)

When the descriptor can't be delivered, `recvmsg` sets `MSG_CTRUNC`, and Mojo only `DCHECK` s that:

```
112  ssize_t result =
113      HANDLE_EINTR(recvmsg(socket, &msg, block ? 0 : MSG_DONTWAIT));
114  if (result < 0) {
115    return result;
116  }
117
118  if (msg.msg_controllen == 0) {
119    return result;
120  }
121
122  DCHECK(!(msg.msg_flags & MSG_CTRUNC));
```

`TryDispatchMessage` gets fewer handles than the header promises and returns `kMissingHandles`.

```
1153    std::vector<PlatformHandle> handles;
1154    if (num_handles > 0) {
1155      if (handle_policy_ == HandlePolicy::kRejectHandles) {
1156        return DispatchResult::kError;
1157      }
1158
1159      if (received_handles) {
1160        handles = std::move(*received_handles);
1161      } else if (!GetReadPlatformHandlesForIpcz(num_handles, handles)) {
1162        return DispatchResult::kError;
1163      }
1164
1165      if (handles.size() < num_handles) {
1166        return DispatchResult::kMissingHandles;
1167      }
1168    }
```

`OnReadComplete` then stops dispatching and leaves the message in the read buffer.

```
1100    DispatchResult result = TryDispatchMessage(
1101        UNSAFE_TODO(base::span(base::unchecked, read_buffer_->occupied_bytes(),
1102                               read_buffer_->num_occupied_bytes())),
1103        next_read_size_hint);
1104    if (result == DispatchResult::kOK) {
1105      read_buffer_->Discard(*next_read_size_hint);
1106      *next_read_size_hint = 0;
1107
1108      if (!DispatchDelayedMessages()) {
1109        return false;
1110      }
1111    } else if (result == DispatchResult::kNotEnoughData) {
1112      return true;
1113    } else if (result == DispatchResult::kMissingHandles) {
1114      break;
1115    } else if (result == DispatchResult::kError) {
1116      return false;
1117    }
1118  }
1119  return true;
```

The 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.

So once a thousand of these replies have piled up, everything freezes dead.

## A repro without claude[#](#a-repro-without-claude)

A page sends 1100 POSTs one after another and keeps every `Response` in an array, like Datadog does, without reading the body.
The server answers with a tiny `no-store` body.

`index.html`:

```
<!doctype html>
<title>start</title>
<pre id="log"></pre>
<script>
// Open as /?keepalive to send the requests with keepalive: true.
const keepalive = location.search === "?keepalive";
const kept = [];
(async () => {
  for (let i = 1; i <= 1100; i++) {
    const r = await fetch("/x", { method: "POST", body: "{}", keepalive });
    kept.push(r);
    if (i % 100 === 0) {
      document.title = "fetched " + i;
      log.textContent += "fetched " + i + "\n";
    }
  }
  document.title = "done";
  log.textContent += "done\n";
})();
</script>
```

## `server.py`

``` python
import http.server, pathlib, sys

PAGE = (pathlib.Path(__file__).parent / "index.html").read_bytes()

class H(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/html")
        self.end_headers()
        self.wfile.write(PAGE)

    def do_POST(self):
        self.rfile.read(int(self.headers.get("Content-Length", 0)))
        body = b'{"ok":true}'
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Cache-Control", "no-store")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *a):
        pass

http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1]) if len(sys.argv) > 1 else 8000), H).serve_forever()
```

Run `python3 server.py 8000`, then `ulimit -Sn 1024`, and start the browser from that shell.
The app's request has `keepalive: true`, so every browser ran with and without it.
The results on Linux with a soft `RLIMIT_NOFILE` of 1024:

|  | without `keepalive` | with `keepalive` | 
|---|---|---|
| Electron 44.9.3 (Chrome 152) | stops at "fetched 900", the page's JS still runs | the page's JS stops | 
| 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 | 
| Chrome 156 headless | stops at "fetched 900", the next `fetch()` never settles | the renderer process disappears | 
| Electron 46 alpha (Chrome 159) | the page's JS still runs, the next `fetch()` never settles | the renderer exits: `clean-exit` , code 0 | 
| Firefox 159 headless | all 1100 done, descriptors stay flat | all 1100 done, one memfd per `Response` | 

Where the page stops, the renderer sits at 1020 to 1024 descriptors.
Without `Cache-Control: no-store` nothing accumulates: the bodies are buffered and the pipes are closed within 250 ms.
On Electron 44.9.3 the page stops at the same place without `kept.push(r)`.

I never reproduced the clean exits the app had on Chrome 152, where the repro only hangs.

## What Chromium says[#](#what-chromium-says)

Chromium 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".
It's Assigned, P3.
Its owner's answer in comment #2:

If 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.

Fine, hold on to it.
But any sane API would fail the next `fetch()` with an error when the descriptors run out.
Instead the renderer freezes dead, and I get to debug it for a month.

I would just buffer small bodies, `no-store` or not.
Shared 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.
I guess it's easier to calculate limits based on 2 MiB regions, but I'm not ready to dive even deeper into Chromium (yet).
Comment #16 on that bug proposes buffering too, for completely received bodies under about 512 KB.
In Firefox an unread body takes no descriptor at all, unless the request has `keepalive`.
With `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`.

I'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."

## Newer Chrome[#](#newer-chrome)

The 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).

## The fix: a missing descriptor now changes the return value

```
112  ssize_t result =
113      HANDLE_EINTR(recvmsg(socket, &msg, block ? 0 : MSG_DONTWAIT));
114  if (result < 0) {
115    return result;
116  }
117
118  // Undocumented behavior: the MSG_CTRUNC flag can occur under FD exhaustion.
119  const bool missing_fds = msg.msg_flags & MSG_CTRUNC;
120  if (msg.msg_controllen != 0) {
121    descriptors->clear();
122    for (cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); cmsg;
123         cmsg = UNSAFE_TODO(CMSG_NXTHDR(&msg, cmsg))) {
124      if (cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_RIGHTS) {
125        size_t payload_length = cmsg->cmsg_len - CMSG_LEN(0);
126        DCHECK_EQ(payload_length % sizeof(int), 0u);
127        size_t num_fds = payload_length / sizeof(int);
128        const int* fds = reinterpret_cast<int*>(UNSAFE_TODO(CMSG_DATA(cmsg)));
129        for (size_t i = 0; i < num_fds; ++i) {
130          base::ScopedFD fd(UNSAFE_TODO(fds[i]));
131          DCHECK(fd.is_valid());
132          // If some FDs are missing, this loop is necessary to release the ones
133          // that were successfully received.
134          if (!missing_fds) {
135            descriptors->emplace_back(std::move(fd));
136          }
137        }
138      }
139    }
140  }
141
142  return missing_fds ? 0 : result;
143}
```

Claude Desktop is still on Chrome 152, without the fix.
With the fix and `keepalive` requests, the renderer exits with code 0, and the page still gets no error.

That's a bit better than the whole renderer freezing, because a hang is ~~pure cringe~~ an interesting design decision.
I'd still prefer an exception, as for any other error: the renderer doesn't die when your internet connection is bad.

## Two reports[#](#two-reports)

To 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.
Chromium closed it the same day as a duplicate of 378822137:

378822137 is still open an has an active idea in it's backlog to implement. Please don't open duplicate bugs.

Fair enough, though mine asked for a different thing.

The fix in the app is one line: `.then(r => r.body?.cancel())` after the fetch, or `navigator.sendBeacon` instead of it.
To 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".
A bot closed the issue with the label `invalid`, and no human replied:

Closing for now — this doesn't appear to be about Claude Code.

The 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.

Until 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:

``` js
fetch(url, {method:"POST", headers, body, keepalive:true}).then(r=>r.body?.cancel()).catch(()=>{})
```

With 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.

I 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.
