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".
Contents
  1. Two months of monkey-patching
  2. The core dump
  3. Reading the dump
  4. A file descriptor leak
    1. The presence request
    2. no-store bodies
    3. The Datadog theory
    4. The descriptor limit
  5. A repro without claude
  6. What Chromium says
  7. Newer Chrome
  8. Two reports

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.

The Claude Desktop window with skeleton placeholders in the sidebar and nothing else

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 }
Ah shit, here we go again

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#

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.

SizeWhat it is
64 GiBguard region in front of the sandbox
1 TiBthe V8 sandbox
288 GiBguard region after the sandbox
32 GiBtwo pools of PartitionAlloc, Chromium's allocator
16 GiBOilpan, the garbage-collected heap for C++ objects
4 GiBthe 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.

// Everywhere else use a 1TB sandbox.
constexpr size_t kSandboxSizeLog2 = 40;  // 1 TB
#endif  // V8_TARGET_OS_ANDROID
constexpr size_t kSandboxSize = 1ULL << kSandboxSizeLog2;

// Required alignment of the sandbox. For simplicity, we require the
// size of the guard regions to be a multiple of this, so that this specifies
// the alignment of the sandbox including and excluding surrounding guard
// regions. The alignment requirement is due to the pointer compression cage
// being located at the start of the sandbox.
constexpr size_t kSandboxAlignment = kPtrComprCageBaseAlignment;

// Sandboxed pointers are stored inside the heap as offset from the sandbox
// base shifted to the left. This way, it is guaranteed that the offset is
// smaller than the sandbox size after shifting it to the right again. This
// constant specifies the shift amount.
constexpr 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.

// Tag information for HeapObject.
const int kHeapObjectTag = 1;
const int kWeakHeapObjectTag = 3;
const int kHeapObjectTagSize = 2;
const intptr_t kHeapObjectTagMask = (1 << kHeapObjectTagSize) - 1;
const intptr_t kHeapObjectReferenceTagMask = 1 << (kHeapObjectTagSize - 1);

// Tag information for fowarding pointers stored in object headers.
// 0b00 at the lowest 2 bits in the header indicates that the map word is a
// forwarding pointer.
const int kForwardingTag = 0;
const int kForwardingTagSize = 2;
const intptr_t kForwardingTagMask = (1 << kForwardingTagSize) - 1;

// Tag information for Smi.
const int kSmiTag = 0;
const int kSmiTagSize = 1;
const intptr_t kSmiTagMask = (1 << kSmiTagSize) - 1;

The guard regions cover the worst case of a typed array access with a corrupted index.

// Size of the guard regions surrounding the sandbox. This assumes a worst-case
// scenario of a 32-bit unsigned index used to access an array of 64-bit values
// with an additional 32GB (bounded size) offset. In particular, accesses to
// TypedArrays are effectively computed as
// `entry_pointer = array->base + array->offset + index * array->element_size`.
// See also https://crbug.com/40070746 for more details.
constexpr size_t kSandboxGuardRegionSize =
    32ULL * GB + (kMaxSafeBufferSizeForSandbox + 1);

V8 also asks the kernel to keep the sandbox out of core dumps.

// Exclude a large virtual reservation from core dumps. Without this, the
// kernel walks the multi-TB sandbox reservation when writing a coredump,
// which can take minutes even though almost none of it is resident. See
// core(5) on MADV_DONTDUMP. No-op on non-Linux platforms; other OSes either
// already skip PROT_NONE mappings or don't expose an equivalent knob.
void ExcludeReservationFromCoreDump(Address base, size_t size) {
#if V8_OS_LINUX
  // Best-effort: ignore failures (e.g. old kernels without MADV_DONTDUMP).
  madvise(reinterpret_cast<void*>(base), size, MADV_DONTDUMP);
#endif
}

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

  if (sysno == __NR_madvise) {
    // Only allow MADV_DONTNEED, MADV_WILLNEED, MADV_RANDOM, MADV_REMOVE,
    // MADV_NORMAL, and MADV_FREE.
    const Arg<int> advice(2);
    return If(AnyOf(advice == MADV_DONTNEED, advice == MADV_WILLNEED,
                    advice == MADV_RANDOM, advice == MADV_REMOVE,
                    advice == MADV_NORMAL
#if defined(MADV_FREE)
                    // MADV_FREE was introduced in Linux 4.5 and started being
                    // defined in glibc 2.24.
                    ,
                    advice == MADV_FREE
#endif
                    ),
              Allow())
        .Else(Error(EPERM));
  }

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#

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.

  // The issue is that we detect the UaF far away from the place where it
  // happens. As a consequence, we should try to make incorrect code crash as
  // early as possible. Poisoning memory at free() time works for UaF, but it
  // was seen in the past to incur a high performance cost.
  //
  // 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.

  char* Reserve(size_t num_bytes) {
    const auto new_size = base::CheckAdd(num_bytes, size_);
    if (!new_size.IsValid()) {
      return nullptr;
    }
    if (num_occupied_bytes_ + num_bytes > size_) {
      size_ = std::max(static_cast<size_t>(size_ * kGrowthFactor),
                       num_occupied_bytes_ + num_bytes);
      AlignedBuffer new_data = MakeAlignedBuffer(size_);
      new_data.copy_prefix_from(data_.first(num_occupied_bytes_));
      data_ = std::move(new_data);
    }

    return data_.subspan(num_occupied_bytes_).data();
  }

Realign moves them to the front of a new buffer.

  void Realign() {
    size_t num_bytes = num_occupied_bytes();
    auto new_data = MakeAlignedBuffer(data_.size());
    new_data.copy_prefix_from(data_.subspan(num_discarded_bytes_, num_bytes));
    data_ = std::move(new_data);
    num_discarded_bytes_ = 0;
    num_occupied_bytes_ = num_bytes;
  }

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#

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

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#

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:

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

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#

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:

// Maximum number of requests to keep in the registry. Requests should be removed quite quickly in
// general, this is just a safety limit to avoid memory leaks in case of a bug.
export const MAX_REQUESTS = 1000

export function createRequestRegistry(lifeCycle: LifeCycle): RequestRegistry {
  const requests = new Set<RequestCompleteEvent>()

  const subscription = lifeCycle.subscribe(LifeCycleEventType.REQUEST_COMPLETED, (request) => {
    requests.add(request)

    if (requests.size > MAX_REQUESTS) {
      requests.delete(requests.values().next().value!)
    }
  })

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 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 DCHECKs that:

  ssize_t result =
      HANDLE_EINTR(recvmsg(socket, &msg, block ? 0 : MSG_DONTWAIT));
  if (result < 0) {
    return result;
  }

  if (msg.msg_controllen == 0) {
    return result;
  }

  DCHECK(!(msg.msg_flags & MSG_CTRUNC));

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

    std::vector<PlatformHandle> handles;
    if (num_handles > 0) {
      if (handle_policy_ == HandlePolicy::kRejectHandles) {
        return DispatchResult::kError;
      }

      if (received_handles) {
        handles = std::move(*received_handles);
      } else if (!GetReadPlatformHandlesForIpcz(num_handles, handles)) {
        return DispatchResult::kError;
      }

      if (handles.size() < num_handles) {
        return DispatchResult::kMissingHandles;
      }
    }

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

    DispatchResult result = TryDispatchMessage(
        UNSAFE_TODO(base::span(base::unchecked, read_buffer_->occupied_bytes(),
                               read_buffer_->num_occupied_bytes())),
        next_read_size_hint);
    if (result == DispatchResult::kOK) {
      read_buffer_->Discard(*next_read_size_hint);
      *next_read_size_hint = 0;

      if (!DispatchDelayedMessages()) {
        return false;
      }
    } else if (result == DispatchResult::kNotEnoughData) {
      return true;
    } else if (result == DispatchResult::kMissingHandles) {
      break;
    } else if (result == DispatchResult::kError) {
      return false;
    }
  }
  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 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
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 keepalivewith keepalive
Electron 44.9.3 (Chrome 152)stops at "fetched 900", the page's JS still runsthe page's JS stops
Electron 45.2.3 (Chrome 156)stops at "fetched 900", the page's JS still runsthe renderer exits after "fetched 900": clean-exit, code 0
Chrome 156 headlessstops at "fetched 900", the next fetch() never settlesthe renderer process disappears
Electron 46 alpha (Chrome 159)the page's JS still runs, the next fetch() never settlesthe renderer exits: clean-exit, code 0
Firefox 159 headlessall 1100 done, descriptors stay flatall 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#

Chromium has a bug for this, 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#

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" for crbug 342779913.

The fix: a missing descriptor now changes the return value
  ssize_t result =
      HANDLE_EINTR(recvmsg(socket, &msg, block ? 0 : MSG_DONTWAIT));
  if (result < 0) {
    return result;
  }

  // Undocumented behavior: the MSG_CTRUNC flag can occur under FD exhaustion.
  const bool missing_fds = msg.msg_flags & MSG_CTRUNC;
  if (msg.msg_controllen != 0) {
    descriptors->clear();
    for (cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); cmsg;
         cmsg = UNSAFE_TODO(CMSG_NXTHDR(&msg, cmsg))) {
      if (cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_RIGHTS) {
        size_t payload_length = cmsg->cmsg_len - CMSG_LEN(0);
        DCHECK_EQ(payload_length % sizeof(int), 0u);
        size_t num_fds = payload_length / sizeof(int);
        const int* fds = reinterpret_cast<int*>(UNSAFE_TODO(CMSG_DATA(cmsg)));
        for (size_t i = 0; i < num_fds; ++i) {
          base::ScopedFD fd(UNSAFE_TODO(fds[i]));
          DCHECK(fd.is_valid());
          // If some FDs are missing, this loop is necessary to release the ones
          // that were successfully received.
          if (!missing_fds) {
            descriptors->emplace_back(std::move(fd));
          }
        }
      }
    }
  }

  return missing_fds ? 0 : result;
}

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#

To Chromium I filed 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, "[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:

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.