[BUG] [Claude Desktop / Linux] Cowork & Dispatch hang on infinite spinner — app opens its own storage directories with O_DIRECT, ext4 returns EINVAL

Status Fixed / completed
Reported on v2.1.246
Maintainer reply None cached
Activity 3 comments · opened Aug 28, 2026 · closed Sep 15, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

This is the Claude Desktop application (claude-desktop 1.37937.3, arm64, installed
from Anthropic's apt repo), NOT the Claude Code CLI.

The Cowork / Dispatch tab in Claude Desktop shows a loading spinner indefinitely and
never becomes usable. No error message is ever displayed. Restarting the app does not
help. The tab spins forever with no timeout and no error state.

Root cause: the app opens its own storage directories under ~/.config/Claude with the
O_DIRECT flag. O_DIRECT on a directory is invalid on ext4, so the kernel returns EINVAL,
and every write path under ~/.config/Claude fails.

strace shows the exact call:

openat(AT_FDCWD, "/home/pi/.config/Claude/vm_bundles/claudevm.bundle",
O_RDONLY|O_DIRECT|O_LARGEFILE|O_CLOEXEC) = -1 EINVAL (Invalid argument)

The same call with the same flags and the same errno fails on:
~/.config/Claude/vm_bundles/claudevm.bundle
~/.config/Claude/claude-code/2.1.246
~/.config/Claude/claude-code-sessions/<org>/<uuid>
~/.config/Claude/local-agent-mode-sessions/<org>/<uuid>
~/.config/Claude/local-agent-mode-sessions/<org>/<uuid>/rpm
~/.config/Claude/local-agent-mode-sessions/<org>/<uuid>/agent/local_ditto_<uuid>/outputs
~/.config/Claude/local-agent-mode-sessions/skills-plugin/<uuid>/<org>/skills

All of these are ordinary directories (drwx------ pi:pi). Confirmed on the same path:
python3: os.open(dir, O_RDONLY|O_DIRECT) -> EINVAL [Errno 22] Invalid argument
python3: os.open(dir, O_RDONLY) -> ok
python3: os.open(dir, O_RDONLY|O_DIRECTORY) + fsync -> ok
node: fs.promises.open(dir, 'r') -> ok

Call sites visible in main.log: ensureStorageDir / writeSessionToDisk,
_syncPlugins, ensurePluginStructure, primeSessionDirs, CCD downloader.

The failure cascades: the stale rootfs.img cannot be refreshed, the Claude Code SDK
2.1.246 download fails 3/3 attempts, session initialisation fails, QEMU is therefore
never spawned (pgrep -a qemu is empty while the app is running), and the UI shows a
spinner forever because the session it is waiting on never initialises.

This is NOT a missing-requirement problem. KVM, vhost_vsock, QEMU and the kvm group
are all correctly set up and the app never reports any of the documented
"Cowork requires ..." messages. See Environment below.

Cowork worked correctly on this machine until 2026-08-28 08:59 (clean VM shutdown in
the logs). It broke on the 09:04 restart, when the app first requested a new
rootfs.img version and could not write it.

I searched existing issues for "O_DIRECT" and "EINVAL" and found no report of this.
Related but not duplicates: #35116 (VM download fails because destination dir can't be
written, Windows/ENOENT), #37925 (Linux downloader failure), #24070 (requests that guest
console output be logged on failure — my kernel-console.log is 29 bytes and empty).

What Should Happen?

  1. The app should not pass O_DIRECT when opening directories. O_DIRECT is meaningless

on a directory and is rejected with EINVAL by ext4 (and other filesystems). A plain
open, or O_DIRECTORY where a directory handle is wanted, works correctly here.

  1. Cowork / Dispatch should start normally: VM bundle refreshes, SDK installs, QEMU

launches, session initialises.

  1. If startup does fail, the UI should surface an actionable error rather than an

indefinite spinner. The Cowork tab already has error states for missing QEMU, missing
KVM and missing vhost_vsock; a total storage failure like this should surface one too,
with a timeout rather than spinning forever.

Error Messages/Logs

--- strace (openat calls returning EINVAL) ---
openat(AT_FDCWD, ".../Claude/local-agent-mode-sessions/<org>/<uuid>/rpm", O_RDONLY|O_DIRECT|O_LARGEFILE|O_CLOEXEC) = -1 EINVAL (Invalid argument)
openat(AT_FDCWD, ".../Claude/vm_bundles/claudevm.bundle",                 O_RDONLY|O_DIRECT|O_LARGEFILE|O_CLOEXEC) = -1 EINVAL (Invalid argument)
openat(AT_FDCWD, ".../Claude/claude-code/2.1.246",                        O_RDONLY|O_DIRECT|O_LARGEFILE|O_CLOEXEC) = -1 EINVAL (Invalid argument)
openat(AT_FDCWD, ".../Claude/claude-code-sessions/<org>/<uuid>",          O_RDONLY|O_DIRECT|O_LARGEFILE|O_CLOEXEC) = -1 EINVAL (Invalid argument)
openat(AT_FDCWD, ".../Claude/local-agent-mode-sessions/skills-plugin/<uuid>/<org>/skills", O_RDONLY|O_DIRECT|O_LARGEFILE|O_CLOEXEC) = -1 EINVAL (Invalid argument)

--- cowork_vm_node.log ---
[Bundle:status] rootfs.img version mismatch: have=6d1538ba…, want=2a762adf…
[setYukonSilverConfig] Stale bundle present; refreshing in background
[setYukonSilverConfig] Stale-bundle refresh failed (non-fatal): EINVAL: invalid argument, open '/home/pi/.config/Claude/vm_bundles/claudevm.bundle'
[linux-vm] Spawning helper: /usr/lib/claude-desktop/resources/cowork-linux-helper
[cowork-linux-helper] [server] listening on /run/user/1000/claude-cowork-vm.sock
[error] [download] VM download failed: EINVAL: invalid argument, open '/home/pi/.config/Claude/vm_bundles/claudevm.bundle'

(last successful run, before the break:)
[VM:shutdown] App quit, stopping VM … [vm] qemu exited: <nil> … Completed in 3644ms

--- main.log ---
[error] [CCD] Download attempt 1/3 failed Error: EINVAL: invalid argument, open '/home/pi/.config/Claude/claude-code/2.1.246'
[error] [CCD] Download attempt 2/3 failed …
[error] [CCD] Download attempt 3/3 failed …
[error] [CCD] All download attempts failed
[error] Failed to save session local_<uuid>: EINVAL: invalid argument, open '.../claude-code-sessions/<org>/<uuid>'
    at async open (node:internal/fs/promises:640:25)
    at async e.ensureStorageDir (…/index2.chunk-CknQXpFk.js)
    at async e.writeSessionToDisk (…/index2.chunk-CknQXpFk.js)
[error] [LocalPlugins] Failed to sync remote plugins: EINVAL … /rpm
[warn]  [SkillsPlugin] Periodic sync failed: EINVAL … /skills
[error] [audit] logger init failed — dropping entries { error: "EINVAL … /agent/local_ditto_<uuid>" }
[error] Session

Steps to Reproduce

  1. Install Claude Desktop 1.37937.3 (arm64) from Anthropic's apt repository on

Debian 13 (trixie), with ~/.config on an ext4 filesystem.

  1. Sign in and use Cowork/Dispatch successfully (worked here until 2026-08-28 08:59).
  2. Restart the app after the server-side rootfs.img version changes, so the app has to

refresh a stale VM bundle.

  1. Open the Cowork / Dispatch tab.
  2. Observe: spinner forever, no error shown. pgrep -a qemu is empty while the app runs.
  3. Confirm the cause directly — this reproduces standalone, no app needed:

python3 -c "import os,sys; d=os.path.expanduser('~/.config/Claude'); os.open(d, os.O_RDONLY|os.O_DIRECT)"
-> OSError: [Errno 22] Invalid argument
while a plain open of the same directory succeeds.

  1. Or capture it from the app:

strace -f -y -e trace=openat -o /tmp/claude-strace.log claude-desktop
grep EINVAL /tmp/claude-strace.log | grep '\.config/Claude'

Claude Model

_No response_

Is this a regression?

Yes, this worked in a previous version

Last Working Version

_No response_

Claude Code Version

2.1.215

Platform

Other

Operating System

Ubuntu/Debian Linux

Terminal/Shell

Other

Additional Information

Product: Claude Desktop app (Linux beta) — not the Claude Code CLI
Version: claude-desktop 1.37937.3, arm64 (apt: downloads.claude.ai stable)
OS: Debian GNU/Linux 13 (trixie)
Kernel: 6.18.34+rpt-rpi-2712, aarch64, page size 16384 (Raspberry Pi 5, 16 GB)
Filesystem: ext4 on NVMe, rw, noatime, 146 GB free, 8% inodes, no I/O errors in dmesg
Virtualisation: /dev/kvm and /dev/vhost-vsock both present; user in kvm group;
vhost_vsock loaded; qemu-system-arm, qemu-efi-aarch64, virtiofsd installed;
qemu-system-aarch64 -accel help lists kvm; systemd-detect-virt = none

The target directory is created but left empty:
~/.config/Claude/claude-code/2.1.215 250M (working, previously installed)
~/.config/Claude/claude-code/2.1.246 4.0K (empty, created 2026-08-28 09:04)
The app successfully creates the directory, then fails on the subsequent
O_DIRECT open of that same directory, so no SDK content is ever written.

View original on GitHub ↗

3 Comments

dave-ct · 1 month ago

Correction — the placeholders in the path list above were stripped as HTML. The
affected directories are:

~/.config/Claude/vm_bundles/claudevm.bundle
~/.config/Claude/claude-code/2.1.246
~/.config/Claude/claude-code-sessions/<org>/<uuid>
~/.config/Claude/local-agent-mode-sessions/<org>/<uuid>
~/.config/Claude/local-agent-mode-sessions/<org>/<uuid>/rpm
~/.config/Claude/local-agent-mode-sessions/<org>/<uuid>/agent/local_ditto_<uuid>/outputs
~/.config/Claude/local-agent-mode-sessions/skills-plugin/<uuid>/<org>/skills

[error] Session initialization failed for local_ditto_<uuid>: EINVAL: invalid argument,
open '.../local-agent-mode-sessions/<org>/<uuid>/agent/local_ditto_<uuid>/outputs'
at async e.primeSessionDirs (…/index2.chunk-Dkhfb34G.js)

--- kernel-console.log ---
(29 bytes, blank lines only — guest never produced output; last written 2026-07-23)

--- also present, likely separate ---
[error] Electron safeStorage encryption is not available on this system, cannot store allowlist cache

mercurious · 1 month ago

Your strace line is the key to this, but I think it's pointing somewhere other than where it looks.

openat(AT_FDCWD, ".../vm_bundles/claudevm.bundle",
       O_RDONLY|O_DIRECT|O_LARGEFILE|O_CLOEXEC) = -1 EINVAL

The app never asked for O_DIRECT. It asked for O_DIRECTORY and passed the x86_64 value for
it. On aarch64 that value happens to mean O_DIRECT, so strace decoded a flag nobody wrote.

The private-directory helper is:

await mkdir(dir, { recursive: true, mode: 0o700 });
const fd = await open(dir, fs.constants.O_RDONLY
                         | (fs.constants.O_DIRECTORY ?? 0)
                         | (fs.constants.O_NOFOLLOW  ?? 0));

Perfectly reasonable code. The constants it reads are wrong. Measured inside Anthropic's own shipped
arm64 Electron (42.10.0 / Node 24.18.1, process.arch === "arm64"):

| constant | arm64 build reports | correct aarch64 value | |
|---|---|---|---|
| O_DIRECTORY | 65536 (0o200000) | 16384 (0o40000) | ← x86_64 value |
| O_NOFOLLOW | 131072 (0o400000) | 32768 (0o100000) | ← x86_64 value |
| O_DIRECT | 16384 (0o40000) | 65536 (0o200000) | ← x86_64 value |

The other 12 O_* flags I checked (O_RDONLY, O_WRONLY, O_RDWR, O_CREAT, O_EXCL, O_TRUNC,
O_APPEND, O_NONBLOCK, O_SYNC, O_DSYNC, O_NOCTTY, O_NOATIME) are all correct. Exactly
the three whose values differ between x86_64 and aarch64 are wrong, and all three carry the x86_64
value — the signature of node_constants.cc compiled against x86_64 fcntl.h while targeting
aarch64.

These constants rotate between the two architectures in the Linux uapi headers:

| constant | x86_64 | aarch64 |
|---|---|---|
| O_DIRECT | 0o40000 | 0o200000 |
| O_DIRECTORY | 0o200000 | 0o40000 |
| O_NOFOLLOW | 0o400000 | 0o100000 |
| O_LARGEFILE | 0o100000 | 0o400000 |

So the intended O_DIRECTORY (0o200000) is O_DIRECT on aarch64 — and O_DIRECT on a directory
is unconditionally EINVAL: do_dentry_open() rejects it because directory inodes have no
direct_IO in their address_space_operations. Not ext4-specific, not filesystem-specific, and no
mount option avoids it. Your O_LARGEFILE in that same strace line is the other half of the rotation
showing up.

Minimal repro, no Anthropic software involved

Plain kernel-level check, runs anywhere on aarch64:

import os
d = "/tmp/probe"; os.makedirs(d, exist_ok=True)

os.O_DIRECTORY, os.O_NOFOLLOW, os.O_DIRECT      # -> 0o40000, 0o100000, 0o200000 on aarch64

os.open(d, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)   # OK      <- what the app means
os.open(d, os.O_RDONLY | 0o200000 | 0o400000)              # EINVAL  <- x86_64 values
os.open(d, os.O_RDONLY | os.O_DIRECT)                      # EINVAL  <- isolates the bit

Line 3 is the intent and it works. Line 4 is the effect and reproduces the exact error. Line 5
isolates the responsible bit.

To read the constants out of the shipped binary directly (runAsNode is fused off, so this needs a
throwaway copy): flip the first fuse byte after the sentinel dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX from
0 to 1 in a copy of claude-desktop, place it beside the real resources/ (otherwise it aborts
with Invalid file descriptor to ICU data received), then:

ELECTRON_RUN_AS_NODE=1 ./claude-desktop-copy -e \
  'const c=require("fs").constants; console.log(process.arch, c.O_DIRECTORY, c.O_NOFOLLOW, c.O_DIRECT)'
# arm64 65536 131072 16384      <- x86_64 values on an arm64 build

This is one bug, not three

I think this also accounts for #89850 and #90354. Same helper, same three constants:

  • #90354 proposes that the binary filename resolves to an empty string, so the downloader opens

the version directory by accident. The observable is identical — open() on the directory, EINVAL
— but the filename is fine; the directory is opened deliberately, to chmod it safely, and it's
the flags that are wrong.

  • #89850 reports the bare EINVAL on arm64 with no root cause. Same helper.
  • This issue is the same fault reached through the Cowork/Dispatch path.

downloadBinaryForTarget() calls the helper before it logs Downloading from … and before it
opens a socket, which is why the CLI download dies with zero network I/O and the 3-attempt retry loop
surfaces it as Download failed. Check your internet connection and try again. That message is
actively misleading and worth fixing on its own
— it sent all three of us looking at the network
first.

Five subsystems share the helper, matching your directory list:

| subsystem | user-visible effect |
|---|---|
| [CCD] binary manager | Claude Code sessions cannot start (hard block) |
| [CCDScheduledTasks] | scheduled tasks never persist |
| [ScheduledTasks] | same, agent-mode side |
| [RemotePluginManager] | plugin sync fails every poll |
| [SkillsPlugin] | skills sync fails every poll |

Plus vm_bundles, which is the spinner you're seeing.

Fix

Derive the flags from the running platform's fs.constants rather than baked-in x86_64 numbers — or,
upstream of that, build node_constants.cc against the aarch64 headers so fs.constants is right for
every consumer, not just this call site.

I patched the asar locally to use the correct aarch64 values and all five subsystems plus the
Cowork/Dispatch bundle load came back green: the CLI downloads, sessions start, scheduled tasks
persist, and plugin/skills sync stops erroring. So the constants really are the whole story here —
nothing else needed changing.

Happy to test a candidate build on Asahi Linux (Apple M1) if that's useful.

PancakeDrip · 1 month ago

Confirming this on a second arm64 machine, and adding two things this thread doesn't have yet: why the flag is O_DIRECT, and how much user data it destroys.

Still present in 1.40609.0

Same failure, newer build — claude-desktop 1.40609.0, aarch64 Linux, kernel 6.17 (NVIDIA DGX Spark, GB10), ext4 on NVMe. It survived at least two releases past the 1.37937.3 reported here. On this machine 1.18286.0 was fine, so the regression landed between those.

Root cause: x86-64 open() constants in the arm64 bundle

The app isn't asking for O_DIRECT — it's asking for O_DIRECTORY|O_NOFOLLOW using x86-64 numeric values, which decode to something else entirely on aarch64:

| Flag | x86-64 value | What that value means on aarch64 |
|---|---|---|
| O_DIRECTORY | 0o200000 (0x10000) | O_DIRECT |
| O_NOFOLLOW | 0o400000 (0x20000) | O_LARGEFILE |

(aarch64's own O_DIRECTORY is 0o40000, O_NOFOLLOW is 0o100000.)

That exactly reproduces the observed O_RDONLY|O_DIRECT|O_LARGEFILE — the intent was a safe directory open, and O_DIRECT on a directory is EINVAL. It also explains why every affected call site is a directory open, and why nothing else in the app misbehaves.

Suggested fix: use fs.constants.O_DIRECTORY / fs.constants.O_NOFOLLOW instead of baked-in numeric literals (or gate the literals on process.arch). Node already exposes the correct per-architecture values.

Severity: this silently destroys sessions, not just Cowork

On this machine the same bug hit ensureStorageDir → writeSessionToDisk 1,241 times over two days, with no error surfaced in the UI. Consequences:

  • Sessions existed only in memory. The durable session list froze at the exact minute of the upgrade.
  • An unrelated reboot then dropped ~36 hours of sessions — they vanished from the sidebar, and their claude.ai tiles were left permanently showing "Remote Control disconnected" because the local↔session mappings were never persisted.
  • Nothing warns the user. The app looks completely healthy right up until a restart deletes the day's work.

Recovery was only possible because the CLI writes its own transcripts to ~/.claude/projects/ independently of the broken store — the app's log still had Mapping internal session <local_id> to CLI session <cli_id> lines, so the store records could be reconstructed and joined back to their claude.ai tiles by timestamp. Without that log, the sessions would simply be gone.

Worth flagging because a spinner in one tab and silent permanent data loss are very different priorities.

Workaround

An LD_PRELOAD shim that rewrites 0x10000|0x20000 back to O_DIRECTORY|O_NOFOLLOW, scoped to paths under ~/.config/Claude, fully restores session saving and goes inert once the constants are correct. app.asar integrity is fused, so patching the bundle isn't an option — which leaves ordinary users with no fix at all.