Back to blog

Lying to Zed to get its remote server running on FreeBSD

How I got Zed's remote development working with a FreeBSD box, and what a 768-byte buffer had to do with it all.

I’ve used VS Code since I was about 16, so for almost ten years and honestly it was mostly fine. But as of late it has become unbearable: slow, and increasingly weighed down by AI slop, on the other hand Zed is fast, and it has a native switch that turns all of its AI stuff off, which I absolutely adore. (It’s a toggle in Settings, or "disable_ai": true in your settings file.)

That’s not because I avoid LLMs. I just don’t want them trying to edit every line I write, or sitting in the corner judging me ominously. I still write most of my FOSS code myself: it keeps me sane, and I feel FOSS developers have a responsibility to keep code clean for LLMs to steal from lol. What I do hand off is the tiring part: regression testing, edge cases, and exploration beyond what my tiny 20-watt brain can hold. For that I use harnesses that work well with models, like OpenCode. Beyond auto-complete.. I’ve always found entire harnesses like claude code etc. very clunky in editors.

Getting it working

Zed has no FreeBSD remote server offering (yet), but FreeBSD can run Linux binaries in spades, so we use Zed’s Linux one. All you need is a FreeBSD server you can SSH into.

1. Turn on Linux compatibility

sudo sysrc linux_enable=YES
sudo service linux start
sudo pkg install linux_base-rl9
kldstat -m linux64    # should print a module

2. Make Zed pick the Linux server

Zed asks the host what platform it runs. FreeBSD says FreeBSD amd64, there’s no server for that, and Zed gives up. This wrapper says Linux x86_64 to that one question and passes everything else to the real uname. Save it as ~/.local/bin/uname and make it executable:

#!/bin/sh
case "$#:$*" in
  1:-sm|1:-ms) printf 'Linux x86_64\n' ;;
  *) exec /usr/bin/uname "$@" ;;
esac

Put ~/.local/bin on PATH for non-interactive SSH sessions. For zsh, add this to ~/.zshenv (use your shell’s equivalent):

export PATH="$HOME/.local/bin:$PATH"

It’s a small lie, but a contained one, and the part I’d love to retire.

3. Connect.

Connect over SSH from Zed and open a folder. The first connection uploads the Linux server to ~/.zed_server/. To watch, run tail -f ~/.local/share/zed/logs/server-workspace-*.log.

4. Run a kernel with the (FreeBSD) fix

Without it you’ll connect fine and get an empty project tree, with this in the server log:

error processing entry failed to read directory entry
Caused by:
    Invalid argument (os error 22)

The fix is ec6629628cb, in main, stable/15 and releng/15.2, so 15.2-RELEASE will include it. If the error persists, check you booted the new kernel with uname -v.

The bug in layman terms

To show your project, Zed asks the server for the names of the files in each folder, a few at a time. Zed sets aside a small amount of space for each batch.

Picture a teacher reading a class roll to a student who writes the names on a sticky note (I couldn’t come up with a better analogy, bare with me). The teacher crosses off each name as she reads it, and she never reads a crossed-off name again. The student’s note has room for 6 names. The teacher reads in groups of 4, a sensible habit that works fine almost every time. But 6 isn’t a whole number of groups, so she reads 8 names and crosses all 8 off. Only 6 fit on the note.

Now she’s in a bind. Her slip was grabbing more than fit before checking. She can’t hand over the 6 that fit and keep the other 2 for later, because they’re already crossed off and she would never read them again. So she reports an error instead, which at least is loud. When I tried the quiet way, 112 of my 1,002 files vanished (from the listing) with no warning. A loud error beats a quiet loss I’m pretty sure. Zed saw the error and decided it couldn’t read the folder, and showed it up empty.

The fix is small: read no more names than fit on the note. Read 6, cross off 6, come back for the next 6, until the roll is done. In the real bug, the teacher is FreeBSD’s Linux translator (Linuxulator), the note is a 768-byte buffer, and a group of 4 names is a 512-byte block.

For the kernel nerds

This is a regression. A February 2026 fix for NFS directories returning bogus entries (110f2567cb51, PR 292282) changed how sys/compat/linux/linux_file.c sizes its native read, in both linux_getdents() and linux_getdents64():

/* before February 2026 */
buflen = min(args->count, MAXBSIZE);

/* after: every request rounded up to a 512-byte block */
buflen = min(roundup2(args->count, DEV_BSIZE), MAXBSIZE);

If the caller’s buffer isn’t a multiple of 512 bytes, the native batch can outgrow it. The emulation layer converts records until one doesn’t fit and returns EINVAL, but kern_getdirentries() has already advanced the directory offset past the whole batch. That’s why returning a partial batch isn’t enough on its own.

The fix (ec6629628cb, reviewed in D60760):

if (args->count >= DEV_BSIZE)
	buflen = min(args->count, MAXBSIZE);
else
	buflen = roundup2(args->count, DEV_BSIZE);

It’s safe because Linux directory records are never larger than FreeBSD’s: for dirent64 the header is 19 bytes against 24, and both round each record up to a multiple of 8. A native batch that fits in the caller’s buffer translates into one that fits too.

Legacy getdents had the same bug for 32-bit and 64-bit callers. My first patch only fixed getdents64; fixing both took the test matrix from 86 failures to zero.

Acknowledgements

I’ve had plenty of patches merged before this one, but this was among the quickest to land in releng/15.2: it didn’t sit in stable for long (thankfully!). The release engineering team (re@) runs a really cool process that I am yet to fully understand in depth. Thank you also to Kyle Evans (kevans@) and Mark Johnston (markj@) for reviewing the patch and for their notes along the way.