Select Wasn't Needed

The classic concurrency toolkit has one famous primitive this language lacks: waiting on several channels at once. A pub/sub broker — the most multi-source program imaginable — went looking for the wall. There was no wall: everything a broker reacts to funnels through one inbox as a variant type, the actor pattern, and the language already had every piece. What the dogfood found instead was a real compiler bug, hiding where two features met: a recursive loop calling a helper whose own nested function closes over the helper's locals leaked those locals into the loop's captures.

mereconcurrencychannelscodegenlanguage-design

Shutdown, timeout — the ending toolkit was two-thirds classical. The third classic is select: waiting on several channels at once, taking whichever produces first. Go has it as syntax; most channel libraries grow some form of it. The natural assumption was that this language needed it too and simply hadn’t been forced yet. So this probe set out to force it, with the most multi-source program available: a pub/sub broker. Publishers on one side, subscribers on the other, a broker in the middle reacting to subscription requests, published messages, and a shutdown signal — three kinds of input, apparently three channels, apparently a select over them.

The funnel

The wall never came. The broker doesn’t need to wait on three channels, because nothing forces the three inputs to be three channels: they can be three constructors of one variant type — Pub, Sub, Stop — flowing through a single inbox, with the broker blocking on exactly one receive and dispatching on the constructor. This is the actor pattern, and the language turned out to have every piece it needs: variants carry tuple payloads across channels, and — the part that genuinely wasn’t obvious — a channel can itself ride inside a message, so a Sub command carries the subscriber’s own delivery channel through the inbox. Channels are first-class message payloads. The general escape is worth stating: any would-be select over sources you control becomes a funnel into one inbox; sources you don’t control get a forwarder thread each, merging into one. A convenience remains a convenience. This is the part’s second negative result done honestly, in the family of “mono is the dictionary”: the missing feature turns out to be subsumed by what exists, and the finding is recorded instead of the feature being built.

What the dogfood actually caught

A dogfood never comes back empty-handed, though — this one caught a genuine compiler bug where two features met. The broker’s fan-out helper contains its own nested recursive function, which closes over the helper’s locals. The broker’s main loop calls the helper. In the C backend’s closure-lifting pass, a fixpoint threads a callee’s captured variables through its callers — necessary in general, but it added the helper’s captures to the loop without checking whether those names were already bound inside the helper itself. The loop’s emitted signature grew two parameters, hn and hv, that exist nowhere in its scope: “use of undeclared identifier,” a compile error two abstraction layers from its cause. The fix is one skip-condition in the fixpoint — a callee capture that is bound anywhere inside the caller’s own body is already in scope and must not be threaded — plus a regression test distilled to a channel-free twenty-liner. The broker now runs its two topics and two subscribers to the correct sums on both native backends. The arc’s ending toolkit stands at: close, timeout, cancel via close — and no select, on purpose. One episode remains in this part, and it plays offense the other way: four classic programs thrown at the language at once, to see if anything still falls over.

← Back to Mere: Building a Language