Skip to content

Wyn v1.21: The Soundness Release

A compiler that says "✓ no errors" and then hands you an error in C you never wrote has lied to you. Worse is the one that says "✓ no errors", builds, runs, exits 0 and prints the wrong answer.

v1.21.0 is 88 fixes and 7 features, and the large majority of those fixes live in exactly that gap. This post is mostly about four of them, because they are the reason to upgrade today rather than next month, and about the two gates we added afterwards - which matter more than any single fix.

One empty TCP connection could kill your server

Here is the whole exploit:

python3 -c "import socket; socket.create_connection(('127.0.0.1',8080)).close()"

Connect. Send nothing. Close. On v1.20.0, the server process is gone:

panic: to_int parse error: "" is not a valid integer

No authentication, one packet. And you do not need an attacker for this: a port scan, a TCP health check, an L4 load-balancer probe and a browser preconnect all open a connection and send nothing. A server would routinely be killed by its own monitoring.

The cause is worth telling, because both halves were individually correct. Http.accept returned the empty string when a connection produced no request. And the documented server pattern is:

wyn
req = Http.accept(server)
fd  = req.split_at("|", 3).to_int()

So "" reached .to_int() - which panics, because we deliberately made to_int loud about bad input instead of silently returning 0. The empty-string signal was survivable while to_int lied. Making to_int honest turned it lethal. Nothing in our entire corpus checked accept for emptiness: not one example, doc snippet or test.

Http.accept now skips a connection that yields no request and keeps accepting, which is what every real server does.

Why our tests missed it for so long: we had a concurrent-load gate. It fires 200 requests at concurrency 20 and checks for fd leaks. It passed. A load generator makes complete requests - the killer is the incomplete one.

HashMap's typed setters corrupted the heap

wyn
var m = HashMap.new()
m.set_int("k", 7)
println(HashMap.get_int(m, "k"))    // v1.20.0 printed 0; v1.21.0 prints 7

Zero, not seven. And under AddressSanitizer, that same ordinary wyn check-clean code reports a heap-buffer-overflow.

m.set_int(...) passed a WynHashMap* to a function that wrote through it as a WynJson* - a type confusion between two unrelated structs. The getters always used the right function, so a set/get pair silently disagreed rather than failing loudly. All four typed setters were affected, in both spellings: the namespace form failed to build outright, and the method form corrupted memory.

For a language whose pitch includes memory safety, this was the most serious thing we found this cycle.

Concurrent collection mutation is now caught

Wyn had three different answers to one hazard. Mutate a shared array from two tasks: runtime panic naming the fix. Mutate a shared scalar global: compile-time error naming the fix. Mutate a shared HashMap:

Nothing. Two writers could splice the same bucket, losing updates or corrupting the deferred-free list - a use-after-free at the next read. It did not corrupt on every run; it corrupted on the unlucky interleaving, which is the worst way for a bug to behave.

All three tiers now agree:

panic: concurrent HashMap mutation detected - HashMap is not thread-safe;
       use a channel or Shared to coordinate writers

We are being precise about the scope: this catches writer-vs-writer, the same as the array guard has always done. A read concurrent with a write is still unguarded, for arrays and collections alike. That needs a different mechanism and we are not claiming it.

This one had a cost we did not dodge: our own "REST API in 93 lines" post mutated a global map from a spawned handler. It usually worked - the dangerous kind of wrong. The post has been rewritten to keep state on one thread, and it now explains why, because that is more useful than the line count was.

A string built inside an if printed its address

wyn
fn main() {
    var n = 1
    if n == 1 {
        s = "v${n}"
        println(s)      // v1.20.0 printed 4345226036; v1.21.0 prints v1
    }
}

Building a message inside a conditional is everyday code. A variable whose first assignment was an interpolated string inside a block got the wrong C type, so printing it showed the pointer as a decimal number. It compiled without a warning - because our own build passes -w, which suppressed the one diagnostic that named the problem exactly.

Also fixed

  • input() was broken six ways. A non-numeric line returned 0 at exit 0. Empty stdin hung forever. 12abc returned 12. It left its newline behind, so a following input_line() returned "". And it truncated to 32 bits, so 4294967297 became 1. input_float() lost precision the same way. It now reads a line and validates all of it, panicking like to_int does; WYN_LENIENT=1 restores the old behaviour if you need it.
  • "${struct}" renders a struct the way you would want - nested structs, arrays and floats at full precision - instead of failing to compile: Box { label: "hi", p: Point { x: 1, y: 2.5 }, tags: [1, 2, 3] }. Note the fix landed on the interpolation path only: println(v) passed a struct that has a nested-struct or array field still type-checks and then fails the C compile. Write println("${v}") for those. That is now on the known limitations list where it belongs, rather than being implied fixed by this bullet.
  • Ok, Err, Some and None work as your own enum variant names again. enum Verdict { Ok, Over } failed to build, and merely declaring such an enum produced a bogus "non-exhaustive match" error on unrelated, correct code.
  • spawn broke handlers named after POSIX functions. fn send(...) worked called directly and failed to build when spawned, because the wrapper called libc's send. read, write, connect and accept were affected identically - the natural names for request handlers.
  • Integer overflow is defined now, not undefined. Wyn ints wrap, two's complement, guaranteed. Previously the optimiser was entitled to assume overflow could not happen, which is how wrapping code turns into deleted comparisons. It costs nothing measurable: an arithmetic-heavy benchmark is unchanged.
  • Json's writer methods silently did nothing in method form. A duplicated struct/enum/fn, negating a non-number, and assigning an int to a struct variable are clean errors now instead of C-compiler noise. Plus the earlier JSON cluster - a remote DoS, key injection, a second parse destroying the first document
    • and mut parameters, which were unusable for every type.

New

  • wyn build --app produces a native, double-clickable application.
  • wyn design [file] launches the Visual Wyn form designer.
  • Function-typed struct fields, so a struct can hold a callback.
  • Non-release builds report -dev, so you can tell what you are running.
  • Registry recipes for tiff, webp, freetype and lcms2.

The part that matters more than any fix

Every defect above was live while our unit suites were green. All of them. So we added the two things that find this class, and both earned their place immediately.

An acceptance gate. One realistic CLI tool that reads stdin, parses JSON, formats numbers, propagates errors across different Result types, passes structs across function boundaries and uses a HashMap from a spawned handler - all in one program, because failures concentrate at composition boundaries. Twenty working 25-line programs do not compose into one working 500-line program. Its first 200 lines found three of the defects above. It did not even build before they were fixed.

A fuzz ratchet with teeth. Our generator emitted no structs, no enums, no match, no collections - which is why it reported zero crashes for months while real defects sat in the corpus. It now emits all of them, including variants named Ok/Err/Some/ None, and every construct in each spelling the language accepts. That last point is the lesson of the whole cycle: four of these defects were "one spelling works and the other doesn't", so testing one spelling per feature made coverage look complete.

We also stopped letting the oracle excuse itself. A build failure after a successful wyn check used to be reported only if the C error matched one of four known phrasings. Anything else was quietly filed as "clean rejection" - which is how several of these stayed invisible. Any post-check build failure is now a violation. It found three more defects the day we changed it.

What we are not claiming

Only about 17% of generated fuzz programs currently type-check, so the build oracle asserts on a handful of programs per run. Raising that requires the generator to track scope and types properly, and it is the next thing we will do to it.

Four items in the known limitations still reproduce - we re-ran every one against this release rather than trusting the list. Two of them are themselves check-passes/build-fails; they are documented rather than fixed because neither is reachable by accident and both fail loudly.

One issue is filed and unfixed: a string handed to a spawned task can outlive an arena that Http.accept resets, which crashes under concurrent load. That is the reason the REST-API example is single-threaded, and the honest candidate for a 1.21.1 if you hit it.

Upgrade

bash
wyn upgrade

Or grab a binary from the release

  • macOS and Linux on both arm64 and x64, Windows on x64, with SHA256SUMS.

If you run a Wyn HTTP server anywhere reachable, upgrade today. One packet.

MIT License - v1.21.0