macOS incorrect colours on external monitor

I bought a new OLED monitor, which worked great with my Windows machine, but when I connected it to my MacBook Air and MacBook Pro via a USB-C to HDMI* adapter the colours all looked very wrong.

Blacks were tinted pink/fuchsia/magenta, whites were tinted yellow/green, all colours look wrong, yet the macOS UI is still legible. Turns out the issue is that macOS thinks the monitor is a TV and encodes colours as YCrCb (or YPbPr) instead of RGB.

photo of Big Sur wallpaper with bad colour encoding
How the Big Sur wallpaper looked
photo of colour gamut with bad colour encoding
The colour information is all there, just… wrong.

Solution

There appear to be distinct fixes for Intel versus Apple Silicon machines.

Intel macs

For Intel macs, the solution is to override the EDID information via a plist, with a base64-encoded EDID payload. I originally tried using patch-edid.rb to generate one, but ended up following this procedure instead:

  1. Dump the EDID data using ioreg -l -d0 -cr -c AppleDisplay. Find the “IODisplayEDID” key for my external monitor. For me, I could exclude the built-in monitor using its IOClass of “AppleBacklightDisplay”. ioreg prints the EDID data as a hex string, which I copied into its own text file. You’ll also want to note the values of “DisplayProductID” and “DisplayVendorID”.
  2. Convert the hex EDID payload to a binary file, e.g. cat edid.txt | xxd -r -p > edid.bin.
  3. Open the binary EDID file with AW EDID Editor. Disable any toggles that mention YCrCb or YCC (there were several of these in the CEA extension block). Slightly confusingly, for me there were also some disabled controls in the EDID Base block under “Color Encoding Formats” (but it turns out it didn’t matter that YCrCb 4:4:4 was still enabled there). Save the EDID file.
  4. Convert the revised binary EDID file to base64 using base64 -i edid.bin.

  5. Fill out the $VARS in the property list template below with the DisplayProductID (as decimal), DisplayVendorID (as decimal) and an arbitrary name string (which will show up in System Settings and such).
  6. Encode the vendor ID and product ID into the override path as hex, e.g.: printf /Library/Displays/Contents/Resources/Overrides/DisplayVendorID-%x/DisplayProductID-%x $VENDORID $PRODUCTID. Move the plist there.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>DisplayProductName</key>
  <string>$NAME can be whatever</string>
  <key>IODisplayEDID</key>
  <data>$EDID as base64</data>
  <key>DisplayVendorID</key>
  <integer>$VENDORID as decimal</integer>
  <key>DisplayProductID</key>
  <integer>$PRODUCTID as decimal</integer>
</dict>
</plist>

Apple Silicon (M1, M2, etc.)

For Apple Silicon, the solution is to override the LinkDescription in /Library/Preferences/com.apple.windowserver.displays.plist. See Force-RGB-color-on-M1-Mac for a great description of this method.

Process

Figuring out the fix was a bit of a process for me: my first few searches suggested it could be a cable problem, but I verified the cables worked just fine with the same monitor and other computers. It also didn’t look like the typical colour aberrations when you’re just “missing” a colour channel. Then I thought it was a problem with the USB-C to HDMI adapter I was using. When I stumbled on old (2013) blog and forum posts I was initially skeptical that it was the same issue or that the workarounds would still work. But it turns out that it is a macOS bug that’s been around for 10+ years, and the old workarounds still work!

* I know DisplayPort would be better, but I want to leave the monitor’s DisplayPort socket permanently plugged into my PC.
† I previously recommended patch-edid.rb for overriding the EDID information, but it drops critical resolution information, making it impossible for macOS to use the native resolution on my 2560×1440 monitor, leaving everything blurry.

keeb.io Iris keyboard review

I have been using an Iris rev 6b split keyboard (FR4 plates, acrylic tented middle layer) for a couple of months.

Until I got the Iris, I’d been packing my Ergodox to work each day, which was inconvenient, and the constant plugging/unplugging was probably not great for the mini USB port either. I wanted:

  • portability, so I can take it on a commute or travel
  • fewer keys (maybe more ergonomic if I learn to use them efficiently?)
  • quietness, so as not to disturb co-workers
  • a split ortholinear layout similar to my Ergodox, so I can rely on muscle-memory between both boards
  • tenting
  • some assembly (to participate in a group custom keeb-building activity)
  • not egregiously expensive

Overall, the Iris did meet my goals, though it required some experimenting/modifications to the hardware and firmware to do so. This review discusses these aspects, mostly in comparison to my Ergodox and similar split/ortho keyboards. It doesn’t discuss the advantages of splits, ortholinear, or <60% layouts.

The Iris keyboard kit cost about $165 USD, including shipping to Australia. Switches and keycaps were another $70 or so (you only need 56 1Us), and maybe $5 USD for the carriage bolts, for a total of $240. This is much cheaper than high-end prebuilt tented ergos: a Dygma Defy is $399, a Moonlander is $365, and an Ergodox EZ Glow is $324 USD.

Continue reading “keeb.io Iris keyboard review”

Go concurrency idioms don’t work for duplex sockets

2022-01-30 ed.: I originally claimed that concurrent method calls on net.Conn weren’t safe, but actually the docs explicitly do guarantee that they are. However, the concern still applies to the io.ReadWriter interface.

The I/O routines in Go’s standard library have an inherently blocking interface: for example, the net.Conn implementation of Read will park the goroutine until the read succeeds or a deadline expires.

Usually that’s okay, because you can wrap that synchronous call in a function that communicates asynchronously over a channel once it’s done:

type readResult struct{
  buf []byte
  err error
}

func ReadAsync(
    r io.Reader,
    ch chan readResult) {
  buf := make([]byte, 512)
  n, err := r.Read(buf)
  if n > 0 {
    ch <- readResult{buf[:n], err}
  } else {
    ch <- readResult{nil, err}
  }
}

ch := make(chan readResult)
rw := dialSomething()
go ReadAsync(rw, ch)

for {
  select {
  case recv := <-ch:
    // do something with recv...
  // multiplex other I/O...
  }
}

This is the idiomatic approach. There's no busy-waiting: underneath the hood Go is actually using asynchronous syscalls (epoll/kqueue) to multiplex all the goroutines that are blocked on I/O. It scales to reading from multiple files/sockets at once (at least until the memory pressure from goroutine and read buffer overheads adds up).

However, this idiom does not work for duplex sockets with an io.ReadWriter interface where we want to multiplex reads and writes asynchronously on the same socket. Wanting a duplex socket is thoroughly reasonable for network sockets or UNIX domain sockets. Take the example below, where we want to copy messages from the channel srcCh to the rw socket, and also print any messages received from rw:

var rw io.ReadWriter
// Initialize rw

ch := make(chan readResult)
for {
  go ReadAsync(rw, ch)

  select {
  // If we have bytes for sending,
  // send them over the socket.
  case sendBytes := <-srcCh:
    // UNSAFE!  Concurrent access to rw :(
    if _, err := rw.Write(sendBytes); err != nil {
      // ...
    }
  // If the socket received a
  // message, print it.
  case recv := <-ch:
    fmt.Println("%v", recv.buf)
    // ...
  }
}

Some error handling has been skipped for brevity here, but there's a more serious concurrency problem. The socket rw is potentially being mutated by two goroutines concurrently: one calling Read() from ReadAsync(), and the other calling Write() from the select block.

For a io.ReadWriter, this isn't safe, because Read and Write potentially mutate opaque internal state, and there are no guarantees in the io docs that this doesn't happen. The compiler (or even the runtime) isn't smart enough to warn about it either, so this unsafe code is extra nefarious because it looks idiomatic. In general there isn't a way to safely multiplex a read from a io.ReadWriter with another goroutine that might call Write.

There's one pragmatic solution, which comes at the cost of generality. There are also three workarounds I've considered, none of which are very appealing.

Solution: use an actual net.Conn

Go's io package API design usually makes things simple: the APIs are blocking, and you can wrap things in a goroutine and a channel if you want to multiplex. The APIs feel familiar if you're used to the equivalent C APIs.

Duplex sockets appear to be an edge case where these design decisions actually work against simplicity, because the idiomatic pattern (that you'd use if you were reading and writing to separate files) is unsafe, and the high-level APIs (io.Reader and such) aren't flexible enough to provide an efficient alternative.

Others have called for the Reader to expose a "ready" API using channels, which I think would be nice. But I also think there's a less intrusive design, whereby io.ReadWriter implementations internally multiplex reads and writes using Go's existing epoll/kqueue-based runtime, and the documentation provides a guarantee that Read and Write can be called concurrently.

This is actually the case for net.Conn! According to the docs:

Multiple goroutines may invoke methods on a Conn simultaneously.

In the examples I gave (network and UNIX domain sockets), you do have a net.Conn interface, so using that type is safe. The downside is that your functions (which need nothing more than Read and Write) require the less general net.Conn interface as a parameter, or must rely on fragile documentation concerning the concurrency guarantees of the io.ReadWriter.

Alternative 1: no multiplexing

We can also use the net.Conn API to handle duplex events by setting a deadline and alternating between reading and writing:

conn.SetReadDeadline(timeout)
for {
  // First check if there is
  // anything to read.
  buf := make([]byte, 512)
  n, err := conn.Read(buf)
  switch {
  case errors.Is(err, os.ErrDeadlineExceeded):
    // Nothing to read; this is fine.
  case err != nil:
    // Actual error handling...
  default:
    // Do something with buf...
  }
  // conn is safe to use:
  // there's no other goroutine calling
  // functions on it.

  // Now check if there's anything
  // ready for sending.
  select {
  case sendBytes := <-srcCh:
    if _, err := conn.Write(sendBytes);
        err != nil {
      // ...
    }
  default:
    // Nothing to send; this is fine.
  }
}

This is now correct, but introduces a horrible trade-off between stutter (high timeouts adding send latency) vs high CPU usage polling for I/O.

Alterative 2: syscall APIs

We can use syscall.RawConn to get access to a raw file descriptor, and EpollWait or Select (from the deprecated syscall or newer golang.org/x/sys/unix packages) to multiplex I/O. This is gross because:

  1. You need to rewrite all your I/O to pass around file descriptors, instead of channels
  2. It's much less portable (BSD and Darwin have kqueue instead of Linux's epoll)
  3. There's much more boilerplate to write - and most of it would be re-implementing the multiplexing already inside Go's runtime/standard library

But at least you get true multiplexing/asynchronous I/O.

Alternative 3: use a third-party library

If you're dealing with I/O that's supported by lesismal/nbio, you can use that for real non-blocking I/O. However, its callback-based design doesn't let you multiplex events arriving over arbitrary channels.

There might be other third-party packages that do something similar?