Wyn v1.22: A Verdict You Can Trust
A compiler that refuses your program has at least told you something. The two defects at the top of this release did the opposite: they accepted the program, ran it, exited 0, and were wrong.
v1.22.0 also carries two remote code executions from the release candidate. If you have not upgraded since v1.21.0, that is reason enough on its own.
Two maps, one type
fn main() {
a = HashMap.new()
a.set("k", 1)
b = HashMap.new()
b.set("k", "s")
print("${a.get("k")}")
print("${b.get("k")}")
}On v1.21.0 that prints 1, then 0.
Not "s". Not an error. Not a warning. Exit 0.
HashMap.new is registered once, with one type node describing its return. Every HashMap.new() call site in a program adopted that same node, so whichever .set() ran first decided the value type for every map in the file. The string map was then read through the int getter, and the int getter returned 0.
The fix is one line, and the interesting part is where it came from. The {} and {:} literal paths already route through a function whose own comment describes this exact aliasing — "otherwise every map in a program aliases one value_type and only the first inference wins". The authority existed. The namespace path was the one place that did not consult it.
It had a second face we found while writing the gate, which the original report did not mention. Two maps built in different functions collided as well, so this valid program did not compile:
fn build_int() -> int { m = HashMap.new(); m.set("k", 5); return m.get("k") }
fn build_str() -> string { m = HashMap.new(); m.set("k", "z"); return m.get("k") }Error: Return type mismatch. Expected string, got intSame root cause, opposite symptom: one silently wrong answer, one rejection of correct code.
This one is in v1.21.0. If you have written Wyn with more than one map in it, this is the line of the release that applies to you.
parallel { } was running your sleeps one at a time
fn main() {
parallel {
Time.sleep(200)
Time.sleep(201)
}
print("both waits done")
}A parallel { } branch only became a real task when the compiler could name a wrapper function after the callee. That excluded every namespaced builtin — Time.sleep, File.read, all of them. So a block like that ran its branches in sequence while reading, to any author, as concurrent.
Eight branches of a 200ms sleep, against one 200ms sleep as the baseline:
| one branch | eight branches | |
|---|---|---|
| v1.22.0 | 204ms | 210ms |
| v1.21.0 | 205ms | 1,649ms |
Three more shapes were broken, and none of them was in the original report — they turned up while fixing the first:
var f = spawn Time.sleep(200)emitted a null future and dropped the call entirely.await freturned0, instantly, having done nothing.spawn Time::sleep(200)did not build — the generated wrapper name contained::, which is not a C identifier.spawn print("hi")did not build.
The reason one fix never covered the others is that . and :: are different shapes in the syntax tree reaching different places in the compiler. Both are handled now.
Wrappers are keyed by the call site as well as the function, so the emitted C is always a plain __spawn_site_<n>. Arguments are evaluated exactly once, at the site, in source order — so side effects and evaluation order match the synchronous call — and the wrapper re-emits the original call expression rather than reconstructing it.
What stays sequential is now refused by name instead of quietly running inline: a method call on a value (its receiver would be copied), a non-scalar argument, a non-word result, more than eight arguments. await on a spawned builtin still yields 0; the difference is that the call now happens.
A benchmark that lied, and how
The gate for this is worth a paragraph, because the first version of it passed with the fix removed.
Two traps. A parallelism fixture whose branches take identical arguments measures the C compiler's common-subexpression elimination, not your runtime: at -O2, four source-level fib(35) calls emit one bl _fib, while four fib_off(35, k) calls emit four. And a fixture whose results are discarded can have the calls deleted outright.
So the branches sleep — Time.sleep is external and side-effecting, so it can be neither folded nor deleted — and every branch takes a different argument. Even then, with the wrapper emitting no call at all, the timing arms reported "overlaps (0ms vs 154ms)" and passed. A bound that only has a ceiling reads zero as perfect. The arms now have a floor as well, and it is absolute against the sleep length rather than a ratio of the measured baseline: both readings are samples that contention can only inflate, and tying one to the other fails a correct measurement when the baseline is the noisy one. That cost us three red CI runs to learn.
The ten Option/Result combinators exist
They had been advertised in the compiler's own method table for several releases while lowering to a representation nothing defined, so they passed wyn check and failed in the C compiler. v1.21.0 removed the rows and refused the calls honestly. v1.22.0 implements them.
fn main() {
o: int? = Some(21)
print("${o.map(fn(x: int) -> int { return x * 2 }).unwrap_or(0)}")
r: Result<int, string> = Err("bad")
m = r.map_err(fn(e: string) -> string { return "wrapped: ${e}" })
print(m.unwrap_err())
}map changes the family: int?.map(fn(x: int) -> string {...}) is an Option<string>, and that is exactly why these are not entries in the method table — a table row carries one concrete return type, so any single answer written there would be wrong.
Scalar payloads only — int, string, float, bool. A struct payload gets a refusal that says what to do instead:
Error: 'map()' needs an Option with a scalar payload
Help: The combinators are lowered over the int/string/float/bool Option families. A
struct payload has a per-program family instead, so branch on the value:
`if o.is_some() { ... }`, or take a default with `o.unwrap_or(d)`.That is the honest shape of a partial feature: it works for the common case and tells you precisely where the edge is.
HashSet remembers what it holds
fn main() {
ints = {:1, 2, 3}
strs = {:"1", "2"}
print("${ints.contains(2)}")
print("${strs.contains("1")}")
print("${strs.len()}")
}{:1} and {:"1"} are different sets now, because entries are tagged and the tag is in both the hash and the equality test. Before this, {:1, 2} segfaulted on construction even if you never used the set, HashSet::add(s, 1) segfaulted, and for x in s was an internal compiler error after passing wyn check. Set algebra also returned something the checker typed as int, so a.union(b).len() could not be written at all.
Mixing types is an error with a real message rather than a crash:
Error: HashSet literal has mixed element types: 'int' and 'string'HashMap keys are still strings. That half is roughly twice the work — it cannot be separated from a runtime change — and it is not started. It is designed and filed.
Also
- The compiler itself segfaulted on two
parallelblocks in one file — in the-O2build, which is the one that ships. A debug compiler was fine, so anyone developing on one could not see it. wyn checkaccepted a void call assigned to an int and thenwyn buildrejected the same file. Two passes disagreed because every function's signature was registered with a flatintdefault before any body was looked at.- Seven runtime string constructors returned buffers with no refcount header —
Uuid.generate,DateTime.to_iso,Net.resolve,Db.escapeand threeEncodingfunctions. Such a string leaks, and makes the next.len()read out of bounds: the refcount probe range-checks the pointer, then reads a header 16 bytes before it. ASan on the runtime reported it 30 runs out of 30, on a test that passes 275 times uninstrumented. Regex.findcould not build on Windows at all. It lowers to lowercaseregex_find, defined only in the POSIX branch; the Windows branch definedRegex_findwith a capital R, so the symbol looked present to anyone grepping and nothing emitted that spelling.
Honest limitations
Every item on the known limitations page was re-run against this release rather than carried forward. Three came off it because they are fixed. These are real and reproduce:
HashMap.set(m, k, 1)segfaults when the value is not a string — the namespace form always emits the string-valued function. Usem.set(k, 1), which picks correctly. This is the map half of the very defect this release fixed for sets, and it is the first thing on the list for 1.23.- An argument of the wrong type is not checked.
"42".pad_left("a", "a")compiles, runs, exits 0, and prints a 44-megabyte garbage string, because the pointer is used as the width. The compiler now records argument types rather than just a count, which is what a check-time rule would read — but no such rule is written yet. - A
Result<T, E>parameter annotation is ignored; the parameter types asint. TheT?form in the same position works. - An unknown method on a map prints an error, drops the call, and still exits 0. An error message that does not fail the build is worse than no message, and it is why our own registry gate now demands a clean compile rather than "the program reached the end".
wyn build --releaseis about 10% slower than v1.21.0 on hello world — 1,392ms to 1,533ms, interleaved medians. Measured, not diagnosed.
.len() is O(1) now, and literals don't pay for it
One more, because it is the only performance item here and because I nearly got it wrong.
string_length memoises into the refcount header, so a StringBuilder result stops re-strlening on every call. 100,000 calls on a 200,000-character builder result: 487ms → 0.18ms. The real property is that it is length-independent — the same 100,000 calls take 0.17-0.20ms whether the string is 10 characters or 200,000, where before they took 0.31ms and 487ms respectively.
The release candidate bought that at the literal's expense. A literal is not RC-managed, so it missed the cache on every call and cached nothing, paying an extra out-of-line call forever — 1.75x on a primitive. That is fixed too: 1M calls on a 44-character literal are back to v1.21.0's cost (1.54ms → 1.62ms, which is spread, not signal).
And the near-miss, since this blog has a habit of documenting them. My first attempt to confirm the speed-up measured 154µs on v1.21.0 and concluded the whole claim was bogus. It was my fixture: with a loop-invariant receiver and nothing else using it, clang hoisted the .len() call clean out of the loop and I timed an empty loop. What exposed it was adding a control — a 10-character receiver beside the 200,000-character one in the same program. If both come back equal, you hoisted; if the long one is a thousand times slower, you are really measuring the call. Same lesson as the identical-argument parallelism fixture two sections up, in a different disguise: a benchmark that looks too good has usually been optimised away, not made fast.
Upgrade
wyn upgradeOr take a binary from the release — macOS and Linux on arm64 and x64, Windows on x64, with SHA256SUMS.
If your code constructs more than one HashMap, or puts a Time.sleep inside a parallel { }, upgrade now. Both were wrong, and neither said so.