Skip to content

Visual X# 0.5.0: lazy evaluation, match, enums, console output, method references and native executables - #42

Merged
Leitwolf11 merged 22 commits into
mainfrom
fix/deep-nesting-limits
Oct 10, 2026
Merged

Leitwolf11 merged 22 commits into
mainfrom
fix/deep-nesting-limits

Conversation

@Leitwolf11

@Leitwolf11 Leitwolf11 commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Draft. No human review has been submitted yet; for a change of this size across language semantics, the optimizer, the wire format, the native stack and the fuzzing infrastructure, one is recommended before merging.

The problem

Deep nesting ended vxs with a stack overflow (0xC00000FD on Windows) and no diagnostic. Reproduced separately on main:

Input Result on main Where it overflowed
else if chain of 2000 links crash Core::CorePrep::Prepare copied every function; the Statement copy constructor recursed about 1550 deep
if nested 100 levels deep crash Core::Wire::Decode, 14.8 KiB of stack per level
sum of 100 operands, 100 nested parentheses, conditional chain of 100 tests crash the same reader

Separately, compile time grew exponentially with the depth of nested loops, quadratically with the length of an else if chain, and with about the 3.5th power of the length of a chain of &&.

What changes

Chains are not nesting. An else if chain, the arms of a match and a chain of a binary operator are as deep in the tree as they are long. Every native stage now walks them in a loop, the native Core expression and statement release their children from a list, and neither the frontend limit nor the wire depth limit counts them. A sum of 50000 operands and an else if chain of 20000 links compile, and the native stages handle 20000 of either on a one-megabyte stack.

Real nesting, measured. //Compiler/Support/Tests:stack_probe runs one native stage on a thread with a stack of a chosen size; the smallest size that completes was found by bisection. KiB of stack per level:

Shape Stage main Now Now, ASan and UBSan
nested statements wire reader 14.8 0.65 1.59
nested statements Core verifier 1.70 0.67 1.70
nested statements CorePrep adapter 3.81 0.31 1.17
nested statements whole native pipeline 14.9 0.67 1.67
nested operands wire reader 7.13 (first commit of this PR) 0.37 1.06
nested operands CorePrep adapter 1.94 (first commit) 1.94 3.39
nested operands whole native pipeline 7.13 (first commit) 1.94 3.39
nested calls, from whole compilations whole compiler not measured 2.5 4.6
operator chain, else if chain every stage 0.4 to 10.6 0 not measured

An earlier version of this description gave 0.42 KiB per expression level. That figure was for an operator chain; nesting in a second operand had not been measured. It is now, with a shape of its own.

Compile time. vxs check, one run each, seconds:

Program Size main this branch
nested for loops 14 1.06 0.13
50 more than 120 0.28
255 more than 120 3.72
else if chain 2000 crash 3.30
4000 crash 10.4
sequential if statements 4000 12.0 7.11
sum of operands 20000 rejected above 1024; 30.4 with the limit lifted 4.57
50000 198 with the limit lifted 9.37
comparisons joined by && 200 47.6 1.13
3000 rejected above 1024 5.60

All measurements, and Criterion before and after, are in Benchmarks/2026-10-04-Nesting-And-Chains.md.

Smoke programs. The execution tables compile every distinct body once, up to eight small bodies per program, and check every run through a bit mask that must be zero in both pipeline modes. They are now a program of their own, source_execution_smoke, beside source_fuzz_smoke; as one program they had grown to 221 seconds in the fuzzing configuration under the 240-second watchdog, and a second attempt that passed was not a fix. No run was removed and the watchdog is unchanged. Measured in that configuration on this machine, three runs each: 77 to 110 seconds for the execution tables (expression 23, branching 35, leaving 17, ownership programs 2 in the run that was broken down) and 100 to 105 seconds for the differential program. Other work ran on the machine at the time. The tables written by hand for single features (inferred returns, evaluation by need, enums, closures) later became a third program, source_feature_smoke, for the same reason: with them the execution program ran past the watchdog. Apart they take 91 and 45 seconds in that configuration.

Language

Implemented from the decisions on the open questions; Spec/Language/Decls.vxs gains examples 295 to 310 and the grammar allows - before a numeric literal in a match pattern.

  1. A numeric constant pattern may be negative. The minus sign belongs to the literal; no other expression is a pattern. The range is checked against the subject's type, including the minimum of a signed type.

  2. A match expression must be exhaustive: a wildcard, a type pattern of the subject's own type, or both values of a bool. A guarded arm never counts. Enum and nullable coverage are specified; enums and nullable subjects are not implemented.

  3. A statement match that selects no arm continues with the next statement.

  4. A block used as a value may leave with return, break or continue under their ordinary rules. A block that leaves has no value; the expression takes its type from the blocks that complete. An expression none of whose branches completes is valid: whether an expression completes is tracked apart from types (Visual.XSharp.Completion), what would have received the value is neither typed against it nor lowered, and no result slot or placeholder is created. Such a block may stand in a loop header and in a loop used as an expression. The condition and the update clause belong to their loop, as decided and now stated in Spec/Language/Iteration.vxs, examples 79 to 83: a break in either leaves that loop; a continue in the condition evaluates the condition of the same loop again, without the body and without the update of a for, and a condition that always continues is an endless loop; a continue in the update clause skips the rest of the update and goes on to the condition; a callable is not inside the loops around the place that creates it. A break may carry a value to the loop expression around the block, and return leaves the method from inside a loop expression. The return type of a callable is inferred from the returns inside its expressions as well, up to nested callables.

  5. A method declared with auto can be called. Its return type is inferred before any caller is checked, across classes and against declaration order, through chains and mutual recursion; a method with no result independent of itself is VXT0063 (example 140 of Decls.vxs), and one whose returns disagree is VXT0062. On main such a call fails in the Core verifier with VXC1018.

  6. A callable may be created inside a callable. The outer one captures what the inner one reads from further out and nothing that belongs to the inner one. On main this fails in the Core verifier with VXC1020.

  7. An if with an else, or a match, that ends a block used as a value is its value, without parentheses. Method bodies are unchanged.

  8. The comma after an arm is optional. A parenthesized pattern after an expression body without a comma is read as a call; VXP0038 says so at the arrow.

  9. A type pattern over a scalar applies no numeric conversion.

  10. The else block of a guard is checked by its control flow.

  11. See "Open: stack and limits".

  12. Classic enums: enum Name { A, B = 2, C }, with an optional underlying integer type. Three points the specification left open were delegated and are now examples 311 to 315 of Decls.vxs: values are compared with == and \= with values of the same enum and nothing else; there is no conversion between an enum and an integer in either direction; and the target-typed .Member spelling is the same as for an enum class. match over an enum uses .Member patterns, treats two members with one value as one case, and needs no catch-all arm when every value is named. .Member is accepted in an expression wherever an enum type is expected. An enum is stored as its underlying integer and never reaches Core as a named type.

  13. Evaluation by need. Visual X# is a lazy, call-by-need language; Spec/Language/Evaluation.vxs now says so. The compiler implements the first part: a local binding of bool, numeric or enum type whose initializer has no effect, that is not assigned again and not captured, is computed by its first read and not at all when nothing reads it. An expression that is certain to read a value by need more than once computes it once ahead of itself; before that, the Core of a chain of bindings that each read the one before twice doubled with every link (found in review of this branch). Reads in different branches of one expression still carry the computation each. Results, other types and assigned or captured variables are still computed where they are written; Documents/EVALUATION.md lists them. The body of a callable evaluates by need as well, and an expression written as a statement is evaluated. Arguments are passed by need as of item 14. Four rules of the new specification file were delegated and are decided as written: effects happen where they are written; a value means what its variables held where it was bound, so laziness never changes the result of a program that also ends under strict evaluation; a value that cannot be computed fails where it is needed; and arguments are passed by need.

  14. Arguments by need. An argument of bool, numeric or enum type that has no effect is computed when the method first needs it, at most once however often the method reads it and however many methods it is handed through, and not at all when no method needs it; a value the caller needs as well is computed once for both. Core has a new primitive, Memoize: a callable without parameters whose result is bool or numeric becomes a callable of the same type that calls it at most once. CorePrep, Xpp and Xmm carry it, each verifier checks its rule, and LLVM lowers it to an AARC object with an invoke thunk and a destructor. The wire versions are Core 9, CorePrep 7, Xpp 6 and Xmm 6. A method with a parameter by need gets a second function that takes suspended computations; the method's own function and ABI are unchanged, and a call with nothing worth suspending still uses it. Still computed at the call: an argument of a call through a callable, an argument a closure of the method captures, arguments of other types and of template methods. A suspended argument costs two runtime objects and two generated functions; that cost is stated in Documents/EVALUATION.md and the changelog.

  15. Native executables. Building the programs of item 14 into executables and running them showed four defects that no test could have seen, because no test ran a produced executable with more than a trivial body. A method without a result whose body ended without a return was closed with an unreachable marker by both Core-to-CorePrep adapters, so a Main without a final return; executed an invalid instruction. An executable is linked without any library, so a program that created a closure did not link; the entry module now defines allocation, retain and release itself over a 64 MiB arena whose released blocks are reused (weak and unowned handles, strings and type tests still do not link). An integer quotient or remainder by zero and a shift outside the width of its operand were lowered to instructions LLVM gives no meaning, and an optimized program ran on past a division by zero it needed; each is now preceded by a check that stops the program, and example 14 of Spec/Language/Evaluation.vxs states that such a value has none. vxs run -File with a relative path looked for the executable it had just built on PATH. A new suite, executable_run_tests, builds programs into executables and runs them as processes; the linker is the Windows one, so those cases run on Windows.

  16. Compile time of the native Core verifier. It copied every visible definition on entering a branch, a loop body, a let or a closure, so its time grew with the square of a function's size: 2.05 seconds for a function of 400 calls with four suspended arguments each, 0.04 seconds now. The execution tables pass every input as a call, so with item 14 their programs grew past the 240 second watchdog under the sanitizers (344 seconds). The expression and leaving tables now run in source_expression_smoke; the watchdog is unchanged and no case was removed.

  17. Console output and strings. Console.Print, Println, Printf, Printfn, the four Error forms and Console.Format are implemented, with System imported implicitly; Examples/HelloWorld/HelloWorld.vxs compiles to a native executable and runs. A format is a string literal checked at compile time: conversions %d %u %x %f %s %c %b, %n and %%, flags -, 0, +, space, # and ', and a width or precision written as *. + joins strings and writes a value that is not a string as text, += appends, and == and \= compare the characters two strings hold, where they used to compare the places the strings were kept. Decided here and written into the specification: ConsoleIO.vxs examples 69 to 85 (which flags each conversion takes, %b, exact rounding of %f with a tie to the even digit, the line terminator of the platform), Operators.vxs examples 86 to 89, and Evaluation.vxs examples 15 and 16: output is an effect, so an expression that writes, itself or through a method it calls, is evaluated where it stands, while a value that only computes is still by need. New diagnostics VXT0071 to VXT0079. Console input, the stream objects, %A, %O, a text form of floating-point numbers outside %f and 128-bit numbers as text report VXT0079; Documents/CONSOLE-IO.md lists them.

  18. The runtime call. Core has a second new primitive: a call of a function of the runtime, named by a literal first operand that is its identity in a catalog of thirteen. CorePrep, Xpp and Xmm carry it, every verifier checks a call against the catalog, and LLVM lowers it to a call of the function's symbol. The type checker rewrites console calls and the string operators into runtime calls, so no later stage knows what a format is. The wire versions are Core 10, CorePrep 8, Xpp 7 and Xmm 7.

  19. The runtime library. A native executable is linked with vxs-runtime.lib: the ownership runtime and the new text and console runtime as one object that needs no C runtime, built from the sources a JIT host links. It replaces the ownership functions that item 15 generated into each executable, and their 64 MiB arena; weak and unowned references, strings and type tests now link. The linker makes the import library for the seven kernel32 functions it uses from a list of names. The development helper and the release bundle put the library beside the compiler. Ownership placement now gives a string literal used as an operand a symbol of its own; such a literal was never released.

  20. More tests, and what they found. EffectTests.hs holds the inference of which calls write from both sides: writes one and two calls down, in mutual recursion, in callables and in methods used as values must happen in the order the program says, and programs that only compute must keep their deferred bindings and suspended arguments. FormatSweepTests.hs writes all 5184 combinations of flags, width and precision over the nine conversion letters and compares the reader of formats with the rules stated a second time. TextRuntimeTests.cpp compares %d, %u and %x with the C library of the host over 44 400 fields and %f over 83 020 numbers. The console tests, the leak-checked smoke program and the tests that run a linked executable gain programs with strings in callables, appending and nested formats. The new tests found a defect of item 17: a method that writes lost its output when it was used as a value (auto f = Log; int x = f(1);, int x = Run(Log);), because the binding was taken to compute only and was deferred. A call through a callable is now taken to write when the body of any callable writes or a method that writes is used as a value; callables that do not write stay by need even inside a method that writes.

  21. Version 0.5.0. 0.4.2 is skipped, and CHANGELOG.md says why under the release heading: evaluation by need and string equality change what existing programs mean, match, guard and enum became reserved, every serialized stage is incompatible with 0.4.1, and an executable needs the runtime library that ships beside the compiler. The formatter, linter and analyzer packages bound the compiler to >=0.5.0 && <0.6; their own versions are unchanged.

  22. Method references, names through the namespace, string conditionals. Type::Method is the static method as a callable value, as section 24.4 of Spec/Language/Operators.vxs has it; with overloads the type the place expects selects one (VXT0080), and a reference through a value reports VXT0081 until values have members. A selector with a dot and no call stays rejected: it is not the specified form. Inside namespace Demo;, Demo.Program.Run(), Demo.Color.Red and Demo.Program::Run name the declarations of that namespace; other namespaces, and a qualified name in a type position, are not connected. condition ? first : second selects strings, through a variable assigned in each arm so that ownership is that of an assignment. QualifiedNameTests.hs, the console tests, the leak-checked smoke program and the tests that run a linked executable cover them.

  23. The scalar buffer, and the tools. Code scanning alert 17 reported the growth of Text::Scalars as an allocation that cannot return null followed by a check for null. The buffer now calls the allocation function by name, ::operator new(bytes, std::nothrow), as the ownership runtime does for an object, and keeps the check. Visual Formatter and Visual Linter are 0.1.1: neither has a new rule, both are built with this compiler, and their tests now hold method references, names through the namespace, string conditionals and console formats. Xide tokenizes :: in Tokenize the method reference sign of Visual X# xide#21.

Pending implementation. These are parts of the specified language, not restrictions of it; each is listed with what it needs under "Pending branching and loop forms" in Documents/IMPLEMENTATION.md:

  • the binding form of guard, if and while (VXP0035): needs optional values;
  • a call that does not return as a way of leaving: needs a way to know that a call does not return; how the language expresses that is not decided here;
  • enums declared inside a class, constant expressions as member values, and enum class;
  • match over a subject that may be null, and type patterns over class hierarchies.

Closures run in the reference Core evaluator of the frontend tests and, through LLVM with the AARC runtime, in source_feature_smoke, unoptimized and optimized: created, called, nested, returned and alive across loop transfers. The smoke program links the runtime and exports its entry points for the JIT; no product target changes.

Ownership placement. Running closures through LLVM showed that every AARC object leaked: no stage wrote a retain or a release. A new Xpp pass, PlaceOwnership, gives every AARC value an owner. Parameters are borrowed and results are owned; a local is released after its last use on each path, on the edge of a branch where it dies there, and directly after its definition when nothing uses it; a copy retains or takes over the reference; a closure owns its captures. A method used as a value, which used to be the address of the method's code, becomes a closure without captures. The pass is checked by an independent reference-count model along every path of each placed function, by the ownership verifiers of Xpp and Xmm, and by the smoke program, which runs 26 closure programs one at a time and fails the one that leaves an AARC allocation behind. Cycles are not collected, and a value now dies at its last use rather than at the end of its source scope.

Generated tables. The branching and leaving cases are written once, in Compiler/Fuzzing/Cases. The Go helper execution-cases generates the C++ rows and the Haskell module from them; check, the helper tests and CI fail on a stale table. One source keeps the two tables equal; it is not an independent oracle. The checks that do not come from these files remain: the expression table, a hand-written table of inferred methods, the match-against-if oracle tests and the differential generator with its host model.

Verification

Local, Windows, on the pushed tree:

  • cabal test all: five suites pass (6832 cases across them).
  • develop test: 28 of 28 native suites.
  • develop sanitize address-undefined: 28 of 28, no report.
  • develop tidy: no first-party finding in 139 translation units.
  • develop fuzz: the five source smoke programs, the wire smoke program and all nine libFuzzer campaigns complete under ASan and UBSan, and the Haskell coverage campaign completes.
  • verify-helpers, verify-docs, verify-benchmarks and execution-cases check pass.

Stack and limits

The limits count real nesting only; chains are not counted. 256 nested statements, 1024 nested expressions and a compiler stack reservation of 256 MiB are the limits this version ships with. They are resource limits of this implementation; the specification states none.

Stack committed by whole compilations (source_stack_probe), Windows x86-64, clang-cl. The figure is committed stack: an upper bound on what the thread used, to the page and with the guard page, not an exact count of bytes in use. Frames that a sanitizer keeps on a heap-allocated fake stack are not in it; the sanitizer runs measured had use-after-return detection off, which is the default of the runtime on Windows.

Program Ordinary build ASan and UBSan
small program 72 KiB 76 KiB
255 nested if, the limit 196 KiB 464 KiB
255 nested value blocks, the limit 784 KiB 1348 KiB
1023 additions nested to the right, the limit 2004 KiB 3484 KiB
1023 nested calls, the limit 2572 KiB 4720 KiB
one level beyond either limit, rejected by the frontend 32 KiB 36 to 40 KiB
sum of 7000 operands, else if chain of 1400, match of 2000 arms 68 to 72 KiB 76 KiB
  • Lowering: at most two extra levels per nest; a level may change kind (a value block is an expression level when pure and a statement level otherwise; && with a storing right operand becomes a conditional statement), so Core from the frontend stays within about 256 + 1024 levels of either kind, inside the wire bound of 4096.
  • Beyond the frontend limits, for Core from a file: 8.2 MiB (4096 nested statements, writer) and 13.3 MiB (4000 nested operands, adapter) under the sanitizers. Those two are measured. Nested calls were not measured at that depth; extrapolated from their slope, about 27 MiB for both kinds at the bound.
  • Reserve and commit: the reservation adds 256 MiB of virtual size and nothing to commit. A small program commits 30.9 MiB on main and 31.0 MiB here; eight at once commit 247.1 and 247.7 MiB in total.
  • Not measured: MemorySanitizer builds, shapes other than nested calls on platforms other than Windows, and several compiler threads in one process, which the compiler does not have.

Measured on every CI platform for the worst shape, 1023 nested calls, as the stack the compiler thread committed (KiB; ordinary build / ASan and UBSan / TSan): Windows x86-64 2580 / 4736 / not run; Ubuntu 26.04 x86-64 2588 / 3060 / 2880; Fedora 43 x86-64 2588 / 3060 / not run; macOS 15 arm64 2480 / 5536 / 2752; macOS 26 arm64 2480 / 5536 / 2768. The largest figure is 2.1 percent of the reservation. The Linux sanitizer figure leaves out the frames the sanitizer runtime keeps on its fake stack.

Not fixed here

  • Time still grows faster than the program on very long functions: by 3.1 from 2000 to 4000 else if links. There is no bound on the number of statements of a function.
  • The CorePrep adapter is the stage that costs most stack per level of real nesting.
  • Copying a native Core module recurses once per level; the pipeline copies only closure bodies.

Leitwolf11 added 2 commits October 4, 2026 13:11
Deep nesting ended the compiler with a stack overflow and no diagnostic.
On the one-megabyte stack a Windows process starts with, a sum of 100
operands, 100 nested parentheses, an `if` nested 100 levels deep and an
`else if` chain of about 1500 links each overflowed.

Two causes, found with stack traces:

- The native stages after Core recurse once per level of nesting, and the
  frames of the Core wire reader are several kilobytes: about 80 levels
  fit in one megabyte.
- The Core-to-CorePrep adapter copied every function before preparing it.
  Copying nested statements recurses once per level, and an `else if`
  chain is one level per link.

Changes:

- Add Visual::XSharp::Support::RunOnCompilerStack and run `vxs`, `vxsi`,
  the source fuzz targets and the smoke program on a thread with 256 MiB
  of reserved stack. The stack is reserved, not committed: on Windows the
  thread is created with STACK_SIZE_PARAM_IS_A_RESERVATION, because the
  default would commit the whole size on every run.
- Add Visual.XSharp.NestingLimits to the frontend. A function body that
  nests statements more than 256 levels deep reports VXP0039 and one that
  nests expressions more than 1024 levels deep reports VXP0040, each once
  per function at the first node that is too deep. An `else if` chain is
  not nesting. The body of a match arm counts one level per arm of its
  match, up to seventeen, which is how deep its lowering nests.
- Bound the nesting of statement bodies in the native Core wire reader
  and writer at 4096 levels, as expression depth already was. The links
  of a chain share a level and an empty body costs none.
- Stop copying functions in the adapter: the work queue refers to the
  functions of the module, and lifted closures live in a deque.

Measured on the compiler stack, the native stages pass 4000 levels of
statements and of expressions. The limits leave room for sanitizer
builds and for the levels a lowering adds.

Verification

- NestingLimitTests.hs nests every construct to the limit and one level
  beyond, checks the position of the diagnostic and runs the accepted
  programs on both forms of Core.
- NestingLimitTests.cpp pins the wire limit and walks 1500 levels and a
  chain of 3000 links through the native Core stages on the compiler
  stack.
- BranchingExecutionCases.cpp runs 255 nested `if` statements and a sum
  of 1024 operands through both pipeline modes. To keep the smoke program
  within its watchdog under sanitizers, the wide match and the long chain
  now run on two subjects each.
- The source corpus gains seeds for deep nesting, for both limits and one
  level beyond each, and for long chains.

Compile time still grows faster than the program for long `else if`
chains, long statement sequences and nested loops; the changelog records
the measurements as known limitations. The specification is unchanged.
Native stack use. The Core wire reader, the Core verifier and the
Core-to-CorePrep adapter held statements, instructions and diagnostic
strings in the frames of the functions that recurse once per level of
nesting. A statement is now read into its place, every kind of statement
and expression has a function of its own, and instructions and
diagnostics are built by functions that return before the next level is
entered. Chains of operators, of conditional expressions and of let
bindings are walked in a loop. Measured with the new stack_probe
program, the native pipeline uses 0.67 KiB of stack per statement level
instead of 14.9 and 0.42 KiB per expression level instead of 10.6; a
sanitizer build uses 1.67 and 0.67. Wire.cpp is split into
Wire/Decode.cpp and Wire/Encode.cpp. The stack size and the nesting
limits are unchanged.

Compile time. The integer analysis repeated the fixed point of an inner
loop on every pass over the loop around it, so each level of nested
loops multiplied its time; it now iterates only loops that hold at most
one further level of loops. The Haskell Core wire encoder, the CorePrep
lowering and the CorePrep verifier no longer copy their output once per
level or per block. Fifty nested loops compile in 0.3 seconds instead of
not finishing, and an else-if chain of 2048 links is lowered in 20 ms
instead of 2.2 s. The Criterion suite gains benchmarks for nested loops,
chains and sequences, and Benchmarks/2026-10-04-Nesting-And-Chains.md
records all measurements before and after.

Smoke program. The execution tables compile every distinct body once
and check all of its runs in that program, with up to eight small bodies
per program, instead of compiling a program per run. All 237 runs are
kept, and the large programs are run on the arguments that had been
removed to save time. The watchdog is unchanged.

Language. The comma after a match arm is optional, as in the grammar. A
pattern binding is an ordinary local and may be assigned. An if with two
value blocks and a match are the value of a value block they end. The
arms of a match lower to one else-if-shaped chain, so the body of an arm
is one level below its match however many arms there are.
Documents/BRANCHING.md describes the implementation and no longer lists
choices as if they were language decisions.

Also fixes the three clang-tidy findings of the previous commit.
@Leitwolf11 Leitwolf11 changed the title Bound nesting depth and run the compiler on its own stack Bound nesting depth, cut native stack use and compile time Oct 4, 2026
Leitwolf11 and others added 11 commits October 4, 2026 20:13
…e limits

Language, following the decisions recorded in Spec/Language/Decls.vxs
examples 295 to 309 and in the grammar:

- A numeric constant pattern may be negative. The minus sign belongs to
  the numeric literal that follows it; no other expression is a pattern.
  The constant is checked against the type of its subject, so the most
  negative value of a signed type is a pattern and a negative constant
  for an unsigned subject is rejected.
- A block used as a value may leave with return, break or continue under
  the rules those statements have anywhere else. A block that cannot
  complete has no value and is lowered as its statements alone; the
  expression takes its type from the blocks that complete, and VXT0062
  reports an expression none of whose blocks does.
- The else block of a guard is checked by its control flow: a loop that
  cannot end and a statement match that always selects an arm and whose
  arms all leave are accepted, as is leaving before the last statement.
- An if with an else whose other block leaves is still the value of a
  value block it ends. Method bodies are unchanged.

Three ways of leaving a value block are recognized and reported as not
implemented: break or continue in the condition or update clause of a
loop, a break that carries a value, and a return where the enclosing
return type is inferred or inside a loop used as an expression. The
binding form of guard still needs optional values.

Chains. A chain of a binary operator no longer counts against the
expression nesting limit: the left operand of a binary operator is at
the level of the operator in the frontend limit and in both Core wire
codecs. The native wire writer walks such chains in a loop like the
reader, the verifier and the adapter, and the destructors of the native
Core expression and statement release operands and nested statements
from a list, so no native stage uses stack per link of an operator chain
or of an else-if chain. A sum of 50000 operands and a chain of 20000
links are handled on a one-megabyte stack.

The native wire reader reads every expression into its place. Nesting in
a second operand cost 7.1 KiB of stack per level and costs 0.37.

Compile time. Constant propagation asked the integer facts about every
node of an expression and the CorePrep lowering collected symbol
identities by appending lists; both were quadratic in the length of an
operator chain. The facts are now consulted for Boolean results only and
only while the condition has at most 256 nodes. A sum of 50000 operands
compiles in 9 seconds instead of 198, and 200 comparisons joined by &&
in 1.1 seconds instead of 47.

The stack size and the limits of 256 and 1024 levels are unchanged.
An if or match expression none of whose branches completes normally is
valid again; VXT0062 is gone. Whether an expression completes is kept
apart from types in a new module, Visual.XSharp.Completion, which the
type checker and the desugarer share. The place that would have received
the value is not held to a type, because it is never reached, and it is
not lowered: the statement becomes the statements that run until control
leaves, with no result slot and no placeholder, and the statements after
it are checked but not lowered. Operands evaluated before the one that
never completes keep their effects. Where some branch completes, the
expected type and the assignment of the result slot follow the
completing paths, as before. Spec example 310 records the rule.

The parts of the specified language that the compiler recognizes and
rejects as not implemented keep their diagnostics and are listed as
pending work, with what each needs, in Documents/BRANCHING.md. The
binding form of guard, if and while is among them. None is written into
the specification as a restriction.

Stack. CommittedStackBytes reports what a thread's stack has committed,
and two measuring programs print it: stack_probe for one native stage
and the new source_stack_probe for a whole compilation. The benchmark
record gains the peak stack of whole compilations at and beyond the
frontend limits in an ordinary and a sanitizer build, the Core nesting
the lowering produces per source level, the reserve and commit of
compiler processes alone and eight at once, and what was not measured.
The largest commit at the limits is 2.5 MiB, 4.6 MiB under the
sanitizers; the reservation costs address space only. The stack size
and the limits are unchanged, and the limits are documented as limits of
this implementation, not of the language.
Five uses of a block used as a value were recognized and rejected as not
implemented. They are implemented now, through the type checker, the
completion analysis, the lowering and the verifiers, and run through LLVM
with and without optimization:

- `break` in the condition of a loop. The condition belongs to its loop, so
  the transfer leaves that loop, with the effects of the condition up to it.
- `continue` in the update clause of a `for`. It ends the update and the
  condition is tested next. An update clause that holds one is lowered to a
  loop that runs once, which the transfer leaves.
- a `break` that carries a value to the loop expression around the block.
- `return` inside a loop used as an expression, directly and from a block.
  A loop expression that returns and never breaks yields no value and is
  valid; one that neither breaks nor returns is still VXT0042.
- `return` in a callable whose result type is inferred. Returns are
  collected through expressions and stop at nested callables. Returns of
  different types are VXT0062, which no longer has its former meaning.

VXT0045 and VXT0047 are retired. VXT0059 remains for the two header cases
that are still pending: `continue` in a loop condition, whose target the
specification does not state, and `break` in an update clause.

The Core verifiers, Haskell and native, accept a body that ends in a loop
that cannot be left: a literal true condition and no `break` of that loop.
Such a loop is what a method that only returns from inside an endless loop
lowers to, and it was reported as a missing return.

Completion analysis: a loop does not complete only when nothing breaks out
of it and its condition is the literal true or never completes; a transfer
inside an expression does not make the expression non-completing unless
every path takes it. Tests cover short-circuit operators, operand order,
match guards, nested loop targets and the condition of a do/while, which
runs after the body and after a `continue` and is skipped by a `break`.

The execution tables leave `source_fuzz_smoke` for a program of their own,
`source_execution_smoke`. As one program they ran 221 seconds in the
fuzzing configuration under a 240 second watchdog, and a second attempt
that passed was not a fix. No case was removed and the watchdog is
unchanged. The new program also compiles and verifies programs that own
closures while control leaves through a value block.

Documentation: the figure of `source_stack_probe` is named for what it is,
committed stack, an upper bound to the page that excludes frames a
sanitizer keeps on a heap-allocated fake stack. The pending list moves from
BRANCHING.md to IMPLEMENTATION.md, and no longer suggests that a `never`
type was decided. It gains two gaps found here that `main` has as well:
calls of methods with an `auto` return type and callables created inside
callables fail in the Core verifier.
…erate tables

Loop headers. The condition and the update clause of a loop belong to that
loop; Spec/Language/Iteration.vxs states the rules in examples 79 to 83:

- a `break` in the condition or in the update clause leaves that loop;
- a `continue` in the condition abandons the rest of the condition and
  evaluates the condition of the same loop again, without the body and, in
  a `for`, without the update; a condition that always continues is an
  endless loop;
- a `continue` in the update clause skips the rest of the update and goes
  on to the condition;
- a callable is not inside the loops around the place that creates it.

The two transfers that were still rejected are lowered. A `for` condition
that holds a `continue` is evaluated in a loop of its own, which the
`continue` repeats, because the Core `continue` of the `for` would run the
update. A `break` in an update clause is the Core `break` there; in an
update clause that is wrapped for a `continue` it sets a flag bound before
the loop. VXT0059 is retired.

Inferred return types of methods. A call of a method declared with `auto`
passed the frontend with an unresolved type and failed in the Core verifier
(VXC1018), on `main` as well. Return types are now inferred before any
caller is checked, in rounds over the whole catalog: a call of a method
that is not inferred yet takes no part, so recursion is inferred from its
base case, chains from their end, across classes and against declaration
order. A method with no result independent of itself is VXT0063, as
example 140 of Decls.vxs requires, and one whose returns disagree is
VXT0062. No verifier check changed.

Callables inside callables. The free names of a callable counted the
parameters, captures and locals of a callable nested in it, so the outer
one captured names that exist only inside the inner one and the Core
verifier rejected the module (VXC1020), on `main` as well. A nested
callable now contributes only what it reads from further out.

Tests. The reference Core evaluator runs closures and leaves a loop from
its update clause. InferredReturnTests.hs runs inferred methods, nested
callables, captures and callables alive across the new transfers on the
unoptimized and the optimized Core. The native smoke program runs the new
transfers and a hand-written table of inferred methods through LLVM, and
compiles nested callables through every stage and its ownership verifier;
its JIT still does not link closures.

Generated tables. The branching and leaving cases are written once, in
Compiler/Fuzzing/Cases. The new Go helper `execution-cases` generates the
C++ rows and the Haskell module from them; its `check` command, its tests
and CI fail on a stale table. This replaces a script outside the
repository. One source keeps the tables equal and is not an independent
oracle; the tables and tests that do not come from these files remain.

Also: the clang-tidy finding of the previous commit, a move inside an
assertion macro in a new test; IMPLEMENTATION.md no longer lists
implemented forms as missing; the nesting limits and the stack reservation
are documented as the limits this version ships with, with the note that
stack use is measured on Windows only; the literal rules of the type
checker move to a module of their own to keep the file within its limit.
No test ran a closure through LLVM: a program that creates one calls the
AARC runtime, the JIT resolves a runtime symbol in the process that hosts
it, and the smoke programs did not link the runtime, so ORC failed on
`vxs_aarc_allocate`.

`source_execution_smoke` now links the runtime and exports its entry
points, with `/EXPORT` on Windows and `-rdynamic` elsewhere, and checks the
ABI version of what it linked. It runs a hand-written table of closures in
both pipeline modes: created and called, nested two and three levels deep
with implicit and explicit captures, returned from the call that created
them and kept, a capture initializer evaluated once, return types inferred
through a value block and a loop expression, and closures alive across a
`break` in a loop condition and in an update clause. The expected values
are worked out by hand from the capture rules.

Neither the JIT session nor any product target changes; the other fuzz
programs still do not link the runtime.
Running closures through LLVM in the previous commit made LeakSanitizer
fail the Linux jobs: every closure object leaked. That was not a fault of
the test. No stage of the compiler wrote a retain or a release; the
vocabulary and its verifiers existed, the placement did not, and nothing
had executed an AARC object before.

`Visual::XSharp::Xpp::PlaceOwnership` runs once on the Xpp form lowered
from CorePrep, after the Xpp optimizer when that is enabled, and gives
every AARC value an owner:

- a parameter is borrowed, a result is owned;
- a local is released after the instruction that uses it last, directly
  after its definition when nothing uses it, and on the edge on which it
  dies when it lives on another edge of the same branch, in a block of its
  own when the target has other predecessors;
- a copy of a value that is used again retains; a copy of a value that is
  not used again takes over the reference;
- a returned parameter is retained for the caller; a discarded owned
  result is given a symbol and released; an instruction that reads the
  symbol it writes reads the old value from a symbol of its own; a
  parameter the body assigns is copied into a local at a new entry block.

Liveness is computed for the owned symbols with the analysis the optimizer
uses. A value that is live at a point is defined on every path to it, so no
release tests whether there is anything to release.

A method used as a value, not called, was the address of its code, and
calling it read an invoke pointer out of that code. The pass makes it a
closure without captures where it is used.

Tests:

- OwnershipPlacementTests.cpp runs each placed function on a
  reference-count model that knows nothing of the pass, along every path
  and twice around every loop: each reference released exactly once,
  nothing used after its last release. The model is first shown to reject
  a leak, a double release and a use after release. The output must also
  satisfy the Xpp ownership verifier.
- The AARC runtime counts its live allocations, a C++ entry point outside
  the C ABI. source_execution_smoke runs 26 closure programs one at a time
  and fails the program that leaves an allocation behind, in both pipeline
  modes and on every platform. With the pass switched off that check fails
  on the first program.
- The new closure programs pass closures to methods, return them, choose
  between them, reassign them in loops and from themselves, create them on
  one branch only, discard them, and name methods as values. The frontend
  tests run the same programs in the reference evaluator, which now treats
  a method value as a closure.
`CommittedStackBytes` returned zero outside Windows, so the stack use at the
nesting limits was measured on one platform. On Linux and macOS it now
counts the resident pages of the thread's stack mapping, which are the
pages the thread has touched, with `mincore`. The initial thread of a
process, whose stack is not one mapping, still reports nothing.

`source_execution_smoke` compiles a program of 1023 calls nested in each
other's arguments, the shape that costs most stack for each level, and ends
by printing the stack its compiler thread committed. On Windows that is
2584 KiB in an ordinary build and 4736 KiB under ASan and UBSan, in line
with the figures recorded earlier for that shape. The line is how the
figure will be read on the other platforms from their CI logs.

The Ubuntu clang-tidy job failed on the previous commit: four assertions of
a new test moved a vector inside the assertion macro. The values are bound
to locals first.
The CI jobs of a0b435a printed the stack the compiler thread committed for
a program of 1023 nested calls, the worst measured shape, on every platform:

  Windows Server x86-64   2580 KiB   4736 KiB ASan+UBSan
  Ubuntu 26.04 x86-64     2588 KiB   3060 KiB ASan+UBSan   2880 KiB TSan
  Fedora 43 x86-64        2588 KiB   3060 KiB ASan+UBSan
  macOS 15 arm64          2480 KiB   5536 KiB ASan+UBSan   2752 KiB TSan
  macOS 26 arm64          2480 KiB   5536 KiB ASan+UBSan   2768 KiB TSan

The ordinary builds are within 5 percent of each other. The largest figure
is 2.1 percent of the 256 MiB reservation. The Linux sanitizer figure leaves
out the frames the runtime keeps on its fake stack, and the benchmark record
says so. The documents no longer say that stack use is measured on Windows
only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`enum Name { A, B = 2, C }` declares a value type whose members are named
integers, with an optional underlying integer type written
`enum Name = byte { ... }`. Members are numbered from zero, a member without
a value follows the one before it, and two members may share a value, as
examples 37 to 43 of Spec/Language/Decls.vxs say.

Three points the specification left open are decided and written into it as
examples 311 to 315:

- values of an enum are compared with `==` and `\=`, with values of the
  same enum, and take part in no other operation: an enum has no order and
  no arithmetic;
- there is no conversion between an enum and an integer in either
  direction;
- the target-typed `.Member` spelling is the same for a classic enum as for
  an enum class.

`Name.Member` is a value of the enum. `match` over an enum uses `.Member`
patterns; a pattern accepts the value of its member, so two members with one
value are one case and the second arm for it is unreachable. A match that
names every value needs no catch-all arm, alone or in combination with other
enum and `bool` subjects, up to 256 combinations.

An enum type carries its underlying type and its distinct values. That is
what lets the lowering store it as an integer and the coverage rule count
its values without a table of declarations. An enum never reaches Core as a
named type: named types are references there.

Not implemented, and listed as pending rather than forbidden: `.Member` in
an expression, enums declared inside a class, constant expressions as member
values, and `enum class`.

Tests: EnumTests.hs pins the declaration, the numbering, every accepted and
rejected operation, and runs 14 programs in the reference evaluator on both
Core forms. source_execution_smoke runs the same programs through LLVM in
both pipeline modes; its harness takes declarations that stand before the
class. `enum` is a reserved word.

The type checker's catalog, template context and type resolution move to a
module of their own to keep the file within its limit.
Visual X# is a lazy language with call-by-need evaluation, thunks that the
source cannot see and an effect system that the source cannot see either.
The specification did not say so: it used the word only for generators,
BLINQ queries and template members, and the compiler evaluated everything
where it was written.

Spec/Language/Evaluation.vxs now states it. A value is computed when it is
first needed and at most once; a value that is never needed is never
computed, so it neither fails nor runs without end; an argument is passed by
need; a value that cannot be computed fails where it is needed. Effects are
not lazy: a store happens where it is written, and a value means what its
variables held where it was bound. `.Strict` and `.AllStrict` belong to
BLINQ and make nothing else strict.

The compiler implements the first part. A local binding of bool, numeric or
enum type that is never assigned again, that no closure captures, and whose
initializer has no effect is computed by the first read that is reached:

- the binding declares the local, a flag, and a copy of each variable its
  initializer reads that is assigned anywhere in the function;
- each read tests the flag and, the first time, evaluates the initializer
  over those copies;
- a binding nothing reads computes nothing.

No new form of Core is involved, so every later stage is unchanged. The body
of a method is lowered twice, the first time only to learn which locals are
assigned and which are captured.

A value is computed in place, without a flag, where no program can tell: its
initializer cannot fail and reads no deferred value, or the next statement
is certain to read it. The second case assumes that a call needs its
arguments, which is true of the compiler and not of the language; the
documentation says that it must be narrowed when arguments are passed by
need.

Still computed where they are written, and listed as pending in
Documents/EVALUATION.md: arguments, results, values of other types,
variables that are assigned again, captured bindings, bindings in callable
bodies and discarded values.

Tests. LazyEvaluationTests.hs observes laziness through a division by zero,
a call that never returns and the step budget of the reference evaluator,
which shows that a value needed fifty times is computed once.
source_execution_smoke runs the same programs through LLVM in both pipeline
modes, where a computed division by zero would end the process. Seven
optimizer tests used an unused division as the thing that must not be
deleted; an unused value is no longer computed at all, so they now read the
value in a condition, which keeps their question the same.
Evaluation by need. The body of a callable now defers its bindings as the
body of a method does, from its own assigned and captured locals, also when
the callable stands inside another. An expression written as a statement,
and a value assigned to the discard, are evaluated: the statement is the
need. Example 12 of Spec/Language/Evaluation.vxs states that rule.

Enums. The target-typed `.Member` spelling is accepted in an expression
wherever an enum type is expected: a declared type, a parameter, a return
type, the right operand of a comparison, the variable assigned to. The
parser keeps the missing receiver as the unit literal, which no source can
write, so no new expression form is needed. Where no enum type is expected
the spelling is VXT0069. A conditional and a loop expression may yield an
enum; they were restricted to bool and numeric results, and an enum is its
underlying integer below the type checker.

One parser test said that a dot without a receiver is rejected. It now pins
the shape the parser gives it, and the type checker's rejection of a call
through it is pinned in EnumTests.hs.

Tests run the new programs in the reference evaluator on both Core forms and
through LLVM in both pipeline modes.

Sharing a value needed twice. Every read of a value by need carries the
computation of that value behind its flag. An expression that read one
value twice carried it twice, so the Core of a chain of bindings that each
read the one before it twice doubled with every link: twelve links were 64
times the Core of six. An expression that is certain to read such a value
more than once now computes it ahead of itself and reads it as an ordinary
local; a chain of 40 links is a test. Reads in different branches of one
expression are not certain and still carry the computation each; that is
recorded as pending in Documents/EVALUATION.md and ends with thunks as
values.

Smoke programs. With the tables for evaluation by need and enums,
source_execution_smoke ran past its 240 second watchdog in the fuzzing
configuration. The tables written by hand for single features, with the
closure programs and the runtime they link, are a third program,
source_feature_smoke. The watchdog is unchanged and no case was removed.
Apart they take 91 and 45 seconds in that configuration on the same machine.

@Leitwolf11 Leitwolf11 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review notes

Scope. This pass read the lowering for evaluation by need (Desugarer.hs, Desugarer/Laziness.hs) and the target-typed .Member path (parser and type checker) line by line. Ownership placement and the rest of the diff were not read in this pass, so these notes say nothing about them. This is not an approval.

Fixed in 56e170b

Core grew exponentially along a chain of values by need. Every read of a value by need carries the computation of that value behind its flag. An expression that read one such value twice carried it twice, so a chain of bindings that each read the one before twice doubled with every link: the Core of twelve links was 64 times the Core of six. An expression that is certain to read such a value more than once now computes it once ahead of itself (neededTwice). LazyEvaluationTests.hs holds the size comparison, which failed before the change, and a chain of 40 links; source_feature_smoke runs a chain of 24 through LLVM.

Open

The same growth remains for reads in different branches. In int b = (c ? a : 0) + (d ? a : 1); neither read of a is certain, so each still carries the computation, and a chain of such bindings still doubles with every link. The result of the program is right; the cost is compile time and code size. Hoisting the computation would evaluate a value that may not be needed, so the fix without changing meaning is a thunk that a read can call. Documents/EVALUATION.md lists it as pending. Until then a short pathological source can make the compiler run very long, which the nesting limits do not catch.

Notes, not blocking

  • .Member is encoded with a sentinel. The parser keeps the missing receiver as a unit literal inside MemberAccessExpression. It works because no source can write that literal, but every pass that walks the AST has to know the convention. A constructor of its own would make a forgotten case a compile error instead of a silent misreading.
  • Calls are treated as free of effects. deferrableExpression lets a call be deferred. That is right today, since the implemented subset has no input, output or shared state, and the document says so. The first built-in with an effect needs an effect analysis before it lands, or the effect moves to the first read.
  • Order of failure. Computing a value ahead of the expression that needs it, here and for a binding the next statement is certain to read, can change which of two failing computations fails first. The comments state this; the specification should state whether that order is observable behaviour a program may rely on.

Leitwolf11 and others added 6 commits October 6, 2026 21:40
Evaluation by need gives the values that one statement needs no order among
themselves. Example 13 of Spec/Language/Evaluation.vxs now says what a
program may rely on: whether it fails is determined, and which of several
failures one statement meets is not. A value that runs without end counts
as one that cannot be computed, so a statement that needs it and a value
that fails may fail or may never end. Statements are carried out in the
order they are written, and so are effects.

The compiler already depends on this in two places: it computes a value in
place when the next statement is certain to read it, and it computes a
value ahead of an expression that is certain to read it more than once.
Documents/EVALUATION.md refers both to the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`vxs check` on one class with 1000 small methods took 4.3 seconds, with 2000
it took 16 and with 4000 it took 73. Five places took time with the square
of the number of functions or members. What each stage accepts and reports
is unchanged.

Native CorePrep verifier. For every function it entered every function of
the module into a table of its own, building the function type of each, and
searched the whole module for closures that capture into the function. That
was 14.4 of 17.7 seconds for 2000 methods. The functions, their spellings
and the captured parameters are now collected once for the module; a
function looks its own definitions up beside them. A function of the module
still takes precedence over a local definition with its identity, a
function's own spelling still comes first, and a conflict among the
function symbols of the module is still reported for every function that
sees it.

Renamer. The names in scope were a list of pairs, so every lookup and every
duplicate check walked all members of the type. They are a map; a later
binding replaces an earlier one, which is the shadowing the list gave.

Type checker. A method was compared with every earlier member to find two
overloads with the same parameters. It is compared with the earlier methods
of its own name.

Haskell Core verifier. Every function was looked up in the list of source
owners; the owners and the source files are sets.

CorePrep lowering. The functions lifted out of closures were appended to
the list of functions still waiting once for every function, also when
there were none, which wrapped the list once more each time. Lifted
functions now wait in batches of their own and are prepared in the same
order as before.

Now 1000, 2000, 4000 and 8000 methods take 1.4, 2.9, 4.8 and 10 seconds.
Benchmarks/2026-10-07-Many-Methods.md has the measurements by stage.

Tests. CorePrepVerifierTests.cpp pins what a function sees of its module:
a call of a function declared later, a module of 3000 functions, an
undefined symbol, a conflicting spelling in a use and among the functions,
the precedence of a function over a binding with its identity, and a
parameter that may be assigned only when a closure captures into it. The
same cases pass on the verifier as it was. The CorePrep benchmarks gain a
case by number of functions, which Google Benchmark fits to linear time.
The value of an enum member could only be an integer literal with an
optional minus sign; the grammar allows an expression there. A member value
is now a constant integer expression: integer literals and earlier members
of the same enum, joined by the arithmetic, shift and bitwise operators, as
in `WRITE = READ << 1` and `ALL = READ | WRITE | EXECUTE`.

Language decision. Inside the declaration of its enum the name of a member
stands for its number: that is where the numbers behind the names are
defined. Everywhere else a member is a value of the enum, and the rules
that there is no arithmetic on enum values and no conversion between an
enum and an integer stand unchanged. A value names earlier members only,
of its own enum only. Examples 316 and 317 of Spec/Language/Decls.vxs
specify this.

The parser keeps the expression as it was parsed; its names are members,
which are named by spelling and take no symbols. The type checker gives the
expression the underlying type of the enum, replaces earlier members by
their numbers and lets the constant evaluator of the language compute it,
so the arithmetic, the rounding of `//`, the complement of `!` for the
width and signedness of the type and the refusal of a division by zero are
those of any other constant. A member without a value follows the computed
one. VXT0070 reports a value that is not such an expression or has no
value; VXT0068 still reports a value its type cannot hold. A member whose
value is refused is numbered as if nothing had been written, so one mistake
is reported once. VXP0041 is retired. The nesting limit of expressions
covers member values.

Tests run enums with computed values in the reference evaluator on both
Core forms and through LLVM in both pipeline modes, and pin each refusal.

Documents/BRANCHING.md and Documents/IMPLEMENTATION.md still said that enum
case patterns are not implemented; they are, and the two passages now say
so.
Arguments by need. An argument of bool, numeric or enum type that has no
effect is computed when the method first needs it, at most once however
often the method reads it and however many methods it is handed through,
and not at all when no method needs it. A value the caller needs as well
is computed once for both.

- Core has a new primitive, Memoize: a callable without parameters whose
  result is bool or numeric becomes a callable of the same type that
  calls it at most once and remembers the result. CorePrep, Xpp and Xmm
  carry it, each verifier checks its rule (VXC1073, VXC0025, VXC1074,
  VXP1047, VXL1053), and LLVM lowers it to an AARC object with an invoke
  thunk and a destructor of its own. Wire versions: Core 9, CorePrep 7,
  Xpp 6, Xmm 6.
- The frontend decides per method which parameters are passed by need: a
  parameter of a suspendable type that the method is not certain to need
  before anything else it does that can be observed. Such a method gets a
  second function that takes suspended computations; its own function
  and ABI are unchanged, and a call with nothing worth suspending still
  uses it.
- A local whose value may be handed on is suspended in a computation of
  its own, so caller and method share one computation.
- Still computed at the call: an argument of a call through a callable,
  an argument a closure of the method captures, arguments of other types
  and arguments of template methods.

Native executables. Building the programs above into executables and
running them showed four defects, none of which a test could have seen,
because no test ran a produced executable with more than a trivial body:

- A method without a result whose body ended without a return was closed
  with an unreachable marker by both Core-to-CorePrep adapters, so a
  Main without a final return executed an invalid instruction. Reaching
  the end of such a body now returns.
- An executable is linked without any library, and code that creates a
  closure calls the ownership runtime, so such a program did not link.
  The module that holds the entry now defines allocate, retain and
  release itself, over a 64 MiB arena whose released blocks are reused.
- An integer quotient or remainder by zero, and a shift outside the
  width of its operand, were lowered to instructions LLVM gives no
  meaning, and an optimized program ran on past them. Each is now
  preceded by a check that stops the program. Example 14 of
  Spec/Language/Evaluation.vxs states the rule. The least value divided
  by minus one is carried out so that it wraps like a sum that does not
  fit.
- vxs run -File with a relative path looked for the executable it had
  just built on PATH.

Tests: MemoizeTests.hs and FallThroughTests.hs (cabal: 5766 pass),
MemoizePipelineTests.cpp, FallThroughTests.cpp,
ComputabilityExecutionTests.cpp, 23 by-need programs in
source_feature_smoke with a leak check each, and executable_run_tests,
which builds programs into executables and runs them as processes.

Cost. A suspended argument is two runtime objects and, in generated
code, a function for the argument and the entry it is called through.
Three changes keep that down: a module has one entry, one destructor and
one metadata record for the callables that remember a result of one
type; a closure that owns nothing has no destructor; and the native Core
verifier enters a branch, a loop body, a let or a closure without
copying every visible definition, which had made its time quadratic in
the size of a function (2.05 s to 0.04 s on a function of 400 calls
with four suspended arguments each). The execution tables pass every
input as a call, so their programs still grew past the 240 second
watchdog under the sanitizers; the expression and leaving tables now
run in source_expression_smoke. The watchdog is unchanged and no case
was removed.
The format check of the Windows job rejected the lambda in main, which
was written by hand and never run through clang-format. No other
tracked source differs from the formatter.
Programs can write. Console.Print, Println, Printf, Printfn, the four
Error forms and Console.Format are implemented, with System imported
implicitly, and Examples/HelloWorld/HelloWorld.vxs compiles to a native
executable and runs.

Language, decided here and written into the specification:

- A format is a string literal, checked when the program is compiled.
  Conversions %d %u %x %f %s %c %b, with %n and %%; flags - 0 + space #
  and '; a width or precision written as * is an int argument before the
  value. Which flags each conversion takes, that %f rounds the exact
  value with a tie to the even digit, and that a line ends with the
  terminator of the platform are ConsoleIO.vxs examples 69 to 85.
- + joins strings and writes a value that is not a string as text; +=
  appends; == and \= compare the characters two strings hold, where they
  compared the places the strings were kept. Operators.vxs examples 86
  to 89.
- Output is an effect: an expression that writes, itself or through a
  method it calls, is evaluated where it stands and in order, while a
  value that only computes is still by need. Evaluation.vxs examples 15
  and 16.

Compiler:

- Core has a new primitive, the runtime call: a function of the runtime
  named by a literal first operand, its identity in a catalog of
  thirteen. CorePrep, Xpp and Xmm carry it and every verifier checks a
  call against the catalog (VXC1075, VXC0026, VXC1076, VXP1048,
  VXL1054). Wire versions: Core 10, CorePrep 8, Xpp 7, Xmm 7.
- The type checker reads the format and rewrites a console call and the
  string operators into runtime calls (VXT0071 to VXT0079); no later
  stage knows what a format is.
- The frontend infers which methods may write, from all methods of a
  program together, and evaluates an expression that calls one where it
  stands instead of deferring or suspending it.
- Ownership placement gives a string literal used as an operand a symbol
  of its own. Such a literal was never released.

Runtime:

- Compiler/Runtime/Text: joining, comparing, the conversions and the
  console write, without a standard container or a C runtime function.
  %f is the exact decimal expansion, from a bounded natural number.
- A native executable is linked with vxs-runtime.lib: the ownership and
  text runtime as one object that needs no C runtime, built from the
  sources a JIT host links. It replaces the ownership functions the
  compiler generated into each executable and their 64 MiB arena. The
  linker makes the import library for the seven kernel32 functions it
  uses from a list of names. The helper and the release bundle put the
  library beside the compiler.

Tests: ConsoleTests.hs and RuntimeCallTests.hs with a second
implementation of the text functions in RuntimeText.hs (cabal: 6607
pass); text_runtime_tests, which compares %f with the host C library;
RuntimeCallPipelineTests.cpp and RuntimeCallVerifierTests.cpp;
source_console_smoke, 83 programs through the JIT with a leak check
each; and executable_run_tests, which now compares the bytes an
executable writes to standard output and standard error.
Comment thread Compiler/Runtime/Text/Scalars.hpp Fixed
Tests

EffectTests.hs holds the inference of which calls write from both sides.
Programs whose writes lie one and two calls down, in mutual recursion, in
callables and in methods used as values must write in the order they say,
before and after optimization. Programs that only compute must keep their
deferred bindings and their suspended arguments, so the inference cannot
buy correctness by giving up evaluation by need.

FormatSweepTests.hs writes every combination of flags, width and
precision for every conversion letter, 5184 formats, and compares what
the reader of formats makes of each with the rules stated a second time.
It also checks properties of a field against the reference text
functions: never shorter than its width, padding only, zeros after the
sign, groups of three.

TextRuntimeTests.cpp compares %d, %u and %x with the C library of the
host over deterministic pseudo-random values of every magnitude, and %f
over 3000 numbers drawn from every exponent and over the multiples of
small powers of two that lie on a rounding tie.

ConsoleTests.hs and SourceConsoleSmoke.cpp gain twelve programs each:
strings that callables take, capture, make and return, appending beside
another name of the same string, string operations in arguments that are
and are not needed, nested Console.Format. The smoke program checks each
for objects left behind.

ExecutableRunTests.cpp gains six cases that run a linked executable: a
method held as a value, strings through callables, appending, a shift by
the width of the type, a program that writes to standard error only, and
that a link leaves no import library or object beside the executable.

Fix

The new tests found that a method that writes lost its output when it
was used as a value. `auto f = Log; int x = f(1);` and `int x = Run(Log);`
were taken to compute only, so the binding was deferred and, when nothing
read it, the call never happened. A call through a callable was taken to
write only when a method that writes also created a callable expression.

A call through a callable is now taken to write when the body of any
callable of the program writes or calls a method that does, or when a
method that writes is used as a value. A program whose callables do not
write keeps their calls by need even when the method around them writes,
which the old rule gave up.

Version

The version is 0.5.0. 0.4.2 is skipped: evaluation by need and string
equality change what existing programs mean, three words became
reserved, every serialized stage is incompatible with 0.4.1, and an
executable now needs the runtime library that ships beside the compiler.
CHANGELOG.md gives the reasons under the release heading. The formatter,
linter and analyzer packages now bound the compiler to >=0.5.0 && <0.6;
their own versions are unchanged.
@Leitwolf11 Leitwolf11 changed the title Bound nesting depth, cut native stack use and compile time Lazy evaluation, match, enums, console output and native executables (0.5.0) Oct 9, 2026
@Leitwolf11 Leitwolf11 changed the title Lazy evaluation, match, enums, console output and native executables (0.5.0) Visual X# 0.5.0: lazy evaluation, match, enums, console output, method references and native executables Oct 9, 2026
Three forms the tests of the last commit ran into. Two were missing; the
third was written in a way the specification does not have.

Method reference

`Type::Method` is the static method as a callable value, as section 24.4
of Spec/Language/Operators.vxs has it: `auto f = Counter::Next;`,
`Apply(Counter::Next)`. The lexer has the token, the parser a postfix
form, and the syntax tree a node that every traversal carries. The type
checker replaces a reference it resolves with the name of the method, so
the stages after it are unchanged, and the inference of which calls
write sees a method that is held as a value.

Where the method has overloads, the type the place expects selects one;
VXT0080 reports a place that selects none. A reference through a value
is a bound method and reports VXT0081 until values have members. A
selector with a dot and no call, `Counter.Next`, is still rejected with
VXT0034: it is not the form the specification gives a reference.

Names through the namespace

A declaration may be named through the namespace it is declared in.
Inside `namespace Demo;`, `Demo.Program.Run()` is `Program.Run()`,
`Demo.Color.Red` is `Color.Red`, and `Demo.Program::Run` is
`Program::Run`. The renamer keeps each declaration of the namespace
under its qualified spelling as well, which no identifier can have, and
turns a selector that spells one into the name. A name of the program
spelled like the first part of the namespace hides it. Other namespaces
are still unknown here, and a qualified name in a type position is not
yet the same type as the bare name.

String conditional

`condition ? first : second` selects strings. A string is an owned
object, so the result is selected into a variable by an assignment in
each arm, which the rules of ownership already cover, and never by an
expression that holds both. The neutral value of a string slot is the
empty string. `?:` is unchanged: its left operand is its test.

Tests

QualifiedNameTests.hs runs the new forms before and after optimization
and holds the rejected ones. ConsoleTests.hs, the leak-checked smoke
program and the tests that run a linked executable select strings,
including a loop of a million passes, and call methods through
references and through the namespace.

@Leitwolf11 Leitwolf11 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review notes, second pass

Scope. This pass read the two commits after the console work: bce33a02 (tests, the effect fix, 0.5.0) and 5b3d8322 (method references, names through the namespace, string conditionals). It is a self-review by the author of the branch, not an independent one; the pull request still has no review from a second person.

What was run. On Windows, on the tree of 5b3d8322: cabal test all (6822 cases), 28 of 28 native suites, the sanitizer build, the fuzz targets with the leak-checked console smoke program, the documentation and example verifiers, and the clang-format dry run. develop tidy last ran on bce33a02; the C++ changed since only by program text in two test files.

Not verified. Linux and macOS for both commits. The new comparisons with the host snprintf have only met the UCRT.

The inline comments are the places I would look at first. None of them blocks the merge in my reading; three of them describe behaviour that is narrower than a reader of the changelog might assume.

Scalar buffer

Code scanning reported the growth of `Text::Scalars` as an allocation
that cannot return null followed by a check for null. The allocation was
`new (std::nothrow) char32_t[n]`, which does return null, so the check
was right; but the form is one an analysis has to recognize, and the
ownership runtime already allocates an object by calling the allocation
function by name. The buffer now does the same:
`::operator new(bytes, std::nothrow)` with `::operator delete`, and the
check for null stays, since it is the only report of failure in a
runtime built without exceptions. The size in bytes cannot wrap: the
capacity is bounded before it is multiplied.

Formatter and linter 0.1.1

Neither has a new rule. Both are built with the compiler of 0.5.0 and
so accept what it accepts; their tests now hold the forms that arrived
with it. The formatter keeps method references, names written through
the namespace, conditionals over strings and console formats as written
while it indents their blocks, and reaches a fixed point on a source
that uses all of them. The linter is clean on such a source, reports a
selector with a dot and no call, a method reference that selects no
overload, a reference through a value and a mismatched format under its
`compiler` check, and keeps a reference intact when it applies its safe
fixes. Their READMEs say which compiler they are built with.
@Leitwolf11
Leitwolf11 marked this pull request as ready for review October 10, 2026 04:00
@Leitwolf11
Leitwolf11 merged commit 1b6d85b into main Oct 10, 2026
81 of 82 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants