Sigil 0.18.6
A compiler correctness release. Optimized builds could silently take the wrong branch inside a loop: no crash, no error message, just a wrong answer from a program with no failing test. Three releases carry it, so anything built by 0.18.3, 0.18.4 or 0.18.5 at optimize: 1 or higher should be rebuilt on this release, and relocked as well, since sigil-stdlib is itself compiled by the compiler and a stdlib pinned inside the exposed window keeps carrying miscompiled code after the toolchain is upgraded. Alongside it, define with keyword parameters works in the REPL, eval stops leaking on every call, and a caller can choose the source address of an outbound connection. Two shapes that used to be accepted are now errors, both cases where the old behaviour silently did nothing.
A loop testing (not x) could take the wrong branch every time
Loop-invariant code motion lifted a (not x) test out of a loop, which is a fine thing to do when x really doesn't change. The bug was in working out what x was: where x came from a join, like the two arms of an and or an if, the optimizer picked one arm's value and treated it as the answer. The test was then computed once, before the loop, from the wrong operand. Every iteration read that one stale result.
The symptom in a real program: folio's integrity scanner reported "no buried task ids found" against a corpus that held six. It read the files correctly, split the lines correctly, and resolved ids correctly. The one branch deciding whether a line matched had been decided before the loop started.
That shape is what makes this worth a release on its own. A crash is cheap to find. An answer that is confidently wrong, from a program with no failing test, is not.
Some specifics worth having:
- Affects 0.18.3, 0.18.4 and 0.18.5. Measured broken on 0.18.3 and 0.18.5.
- Fires at
optimize: 1and above, on both backends. Switching between bytecode and native doesn't avoid it. - 0.18.2 and earlier are unaffected. Builds before August 5th shipped with the CPS optimizer switched off entirely, which is the only reason the exposure window is three releases and not three years.
- Dev and test configs are
optimize: 0, sosigil eval, dev builds and the test suite were all structurally incapable of seeing it. That is why it survived three releases and several investigations.
define with keyword parameters works in the REPL
This was accepted in the REPL and bound nothing:
(define (f x (keys: (y 1))) (+ x y)) (f 2) unbound variable 'f'
The same form in a file has always worked. Runtime eval shared a code path with the evaluator that runs macro transformers, and that path deliberately skips expansion, so core's define macro never ran and the primitive converter compiled the form instead. Runtime eval now has its own expanding path, which also fixes (: ...) type specs and every other core macro used under eval.
sigil eval, file execution and MCP eval were never affected. The REPL was.
A malformed parameter list says where it is
(define (f 42) ...)
A non-symbol where a parameter name belongs used to compile without complaint and bind nothing, so the failure surfaced later as an unbound variable pointing at the call site rather than the definition. It's a located error now, naming the offending formal.
This is one of the two shapes that used to be accepted and now isn't.
eval stops leaking on every call
Every successful eval leaked. Over 100,000 evals, peak memory went from 242 MB to a flat 76 MB. If you run a long-lived process that evaluates code, this is the change you'll notice.
Choosing the source address of an outbound connection
A new primitive, %tcp-connect-ip-from, binds the local end of a socket to a source address you choose before connecting, so the peer sees the connection arriving from that address. Only the address is pinned. The kernel still picks the port, so rapid reconnects don't fail while a previous connection sits in TIME_WAIT.
This is purely additive. No existing signature changed.
Known regressions
The release-configuration test suite does not run to completion. A release binary built from this source dies with a segmentation fault partway through the standard library tests, after about 124 of 158 files. It dies between files, having finished test-random.sgl and while reaching for test-async-io-fairness.sgl, with async and subprocess children still live. This is not new in 0.18.6. Released 0.18.5 does the same thing at the same point, and so does every tree we measured in between: five reproductions across four source trees, deterministic every time.
It is invisible to the ordinary test suite, which runs at optimize: 0, completes all 158 files, and reports the same 2,713 passes on either side of this release's changes. The crash appears only in a release-configuration binary, which is the one surface that suite exists to cover.
We found it because this release is the first to run that suite as part of verifying a compiler change, and we are disclosing it rather than shipping quietly around it. It is not caused by anything in this release, and the fixes here are verified independently of it. It is being investigated on its own.
Loop-invariant code motion now performs no hoists at all in any build we measured. Before the fix it performed twelve, and all twelve were instances of this bug. Legitimate loop-invariant shapes measured zero hoists on the fixed and unfixed compilers alike. So this release removes unsound optimization without adding sound optimization, and if you were counting on that pass for performance, you were not getting it. Whether the pass earns its place at all is an open question we'd rather answer deliberately than fold into a correctness fix.
eval still leaks roughly 1.3 kB per failing eval. Successful evals are flat. A process that evaluates untrusted or malformed code in a loop will still grow.