.release-notes/next-release.md
use=dtrace builds on FreeBSDBuilding ponyc with use=dtrace failed on FreeBSD: the runtime build aborted with dtrace: failed to link script ... No probe sites found for declared provider, and even past that, programs compiled by a dtrace-enabled ponyc could not be linked.
use=dtrace now builds and links correctly on FreeBSD, and dynamically-linked programs expose their pony provider probes to DTrace.
--static programs build and run but do not expose their probes: FreeBSD registers DTrace USDT probes through the runtime linker, which statically-linked programs don't use.
When ponyc is built with sanitizers (such as address_sanitizer or undefined_behavior_sanitizer) on Linux, it now links the programs it compiles with its built-in LLD linker, the same as every other build, instead of falling back to your system C compiler to perform the link. Sanitizer-enabled native Linux compilation no longer depends on having an external compiler driver present and usable as a linker.
A control expression that "jumps away" with no value — error, return,
break, or continue — has no type. Using one in several positions crashed the
compiler instead of compiling or reporting a clear error.
A repeat loop whose else clause jumps away now compiles correctly:
actor Main
new create(env: Env) =>
try let x: U8 = repeat 1 until false else error end end
Using a jump-away expression where a value is required now produces a clear
compile error instead of crashing the compiler. This covers many positions,
including conditions, match operands and guards, recover operands, call and
FFI arguments, method receivers, as and identity (is/isnt) operands,
default arguments, lambda captures, and tuple elements:
// each of these now reports an error rather than crashing the compiler
if error then U8(1) else U8(2) end
match error | let y: U8 => y else U8(0) end
recover error end
let n: U8 = some_function(error)
(error).string()
let t: (U8, U8) = (1, (error))
A generic type parameter whose constraint referred back to itself used to crash the compiler. For example:
class A[B: (B | C)]
The compiler now reports a clear error for these constraints instead of crashing.
Some generic code instantiates itself with an ever-growing type argument, requiring an unbounded number of concrete types. For example, a function that calls itself with a deeper type on each step:
primitive Bar
fun apply[A: IFoo](n: USize): IFoo =>
if n == 0 then
A
else
Bar.apply[Pair[A]](n - 1)
end
Previously the compiler tried to generate every one of those types and kept going until it exhausted all available memory and crashed, with no indication of what in your code caused it. The compiler now stops once a generic instantiation grows past a fixed limit and reports an error pointing at the generic function or type responsible, so you get a clear diagnostic instead of an out-of-memory crash. Genuinely recursive generic types like this remain unsupported — Pony has to know every concrete type ahead of time — but the failure is now explained rather than silent.
Partially applying a method whose default argument is built from a numeric literal would crash the compiler. For example, this crashed during compilation:
use "format"
actor Main
new create(env: Env) =>
Format~apply()
Format.apply has a parameter whose default argument is -1, and partially applying the method triggered the crash. Any method with a comparable default (for instance 0 + 1) was affected. These now compile and behave correctly.
Relatedly, partially applying a method whose default argument is itself invalid — such as (-1).abs(), where the literal has no type to look up abs on — now reports a normal compile error instead of crashing the compiler.
The json package can now serialize any JsonValue — objects, arrays, and scalars alike — to a JSON string via the new JsonPrinter primitive. It is the dual of JsonParser: where JsonParser.parse turns a String into a JsonValue, JsonPrinter.print turns a JsonValue back into JSON.
Previously only JsonObject and JsonArray could be serialized; scalar values had no correct serializer (printing None produced None instead of null, and strings were not escaped). JsonPrinter handles the whole JsonValue union, so this is also the answer to "how do I serialize my data as JSON?": build a JsonValue, then hand it to JsonPrinter.
let doc = JsonObject
.update("name", "Alice")
.update("age", I64(30))
JsonPrinter.print(doc) // {"name":"Alice","age":30}
JsonPrinter.pretty(doc) // pretty-printed, two-space indent by default
JsonPrinter.print(None) // null
JsonPrinter.print("hi\"there") // "hi\"there"
JsonObject and JsonArray no longer implement Stringable. Their string() and pretty_string() methods have been renamed to print() and pretty_print(), matching the new JsonPrinter and the parse/print naming in the package.
This is a breaking change. Code that serialized a JsonObject or JsonArray needs to call the new method names, and code that relied on these types being Stringable (for example passing one where a Stringable is expected) should use JsonPrinter.print instead.
// Before
let s = my_object.string()
let p = my_array.pretty_string()
// After
let s = my_object.print()
let p = my_array.pretty_print()
// or, for any JsonValue including scalars:
let s' = JsonPrinter.print(my_value)
Several while and repeat loops whose body and/or else clause jump away crashed the compiler or, with a debug build, produced invalid code, instead of compiling or reporting a clear error. For example, all of these were broken:
actor Main
new create(env: Env) =>
// (1) jumps-away loop with an uninferable literal else
try repeat error until false else 2 end end
// (2) jumps-away loop with a value else whose result is used
let x: U8 = try repeat error until false else U8(2) end else U8(0) end
// (3) a break with a value while the else jumps away (while and repeat)
try repeat break U8(3) until false else error end end
try while true do break U8(3) else error end end
This is fixed:
break that carries a value gives its loop both a value and an exit, so the loop no longer crashes when its else clause jumps away — it compiles and yields the break value (case 3). The same is true of a continue that reaches a value-producing else. This applies to both while and repeat.try (case 2). It simply produces no value.else clause, or as a break value with nothing to anchor it) is now rejected with a "could not infer literal type" error instead of crashing the compiler (case 1).One related change: a while or repeat loop that jumps away makes any code after it unreachable, so the compiler now reports unreachable code for it — the same error an equivalent if already gives. This affects a loop used as the body of a function with no explicit return, such as while true do break else return end, which previously compiled.
When ponyc is built with sanitizers (such as address_sanitizer or undefined_behavior_sanitizer) on FreeBSD, it now links the programs it compiles with its built-in LLD linker, the same as every other build, instead of falling back to your system C compiler to perform the link. Sanitizer-enabled native FreeBSD compilation no longer depends on having an external compiler driver present and usable as a linker.
Tuple types can't be used as generic type constraints. The compiler already rejected a tuple smuggled into a constraint through a type alias, including when it was hidden inside a union. It did not, however, catch a tuple hidden inside an intersection, so the following incorrectly compiled:
type R is (U8 & (U8, U32))
class Block[T: R]
The compiler now rejects this with the same error it already gives for tuples in unions:
constraint contains a tuple; tuple types can't be used as type constraints
When ponyc is built with use=dtrace on FreeBSD, it now links the programs it compiles with its built-in LLD linker, the same as every other build, instead of falling back to your system C compiler to perform the link. A use=dtrace compiler no longer depends on having an external compiler driver present and usable as a linker, and the programs it builds register and fire their pony provider DTrace probes exactly as before.
The supported version of OpenBSD is now 7.9. Our continuous integration builds and tests ponyc against OpenBSD 7.9.
We won't intentionally break OpenBSD 7.8, but we are no longer actively maintaining support for it. If you need ponyc on OpenBSD, we recommend running 7.9.
Native macOS sanitizer builds (use=address_sanitizer, use=undefined_behavior_sanitizer) now link through the embedded ld64.lld linker instead of requiring an external linker.
Building ponyc with use=dtrace failed on macOS because the build tried to run dtrace -G, a flag that macOS dtrace has never supported. macOS's linker resolves DTrace probe symbols natively, so the -G step was never needed — only the dtrace -h header generation step is required.
use=dtrace now builds and links correctly on macOS. Programs compiled by a dtrace-enabled ponyc expose their pony provider probes to DTrace. Actually tracing a running program requires System Integrity Protection (SIP) to permit DTrace; see the examples/dtrace README for details.
The compiler could crash intermittently when more than one compilation ran at the same time within a single process, as the Pony language server does while you edit. Because the failure depended on thread timing, it appeared as occasional, hard-to-reproduce crashes rather than a consistent error.
Concurrent compilations no longer share state that simultaneous access could corrupt, so these crashes no longer happen.
A self-referential iftype subtype condition — one whose subtype check refers back to the tested type parameter through a union, intersection, or tuple — is meaningless and is rejected when written in a method. The same condition inside a lambda or object literal was silently accepted. It is now rejected everywhere with the same error.
The following program previously compiled but now reports a clear error:
actor Main
new create(env: Env) =>
let f = {[A](x: A) =>
iftype A <: (A | None) then None end}
f[U8](0)
DTrace isn't supported on DragonFly BSD or OpenBSD. make configure use=dtrace now rejects the option on those platforms with a clear, platform-specific message rather than a confusing build failure or a generic error.
--linker and --link-ldcmd command line optionsponyc now links every supported configuration with its embedded LLD linker, so the options that selected an external linker have been removed. --link-ldcmd already had no effect on the embedded linker, and --linker was an escape hatch back to the external linker, which no longer exists.
If you used --linker to link an additional library, declare it in your Pony source instead.
Before:
ponyc --linker="ld -lFoo"
After:
use "lib:Foo"
use "path:..." adds a library search path the same way. There is no longer a way to pass arbitrary linker flags directly; if your build relies on that, let us know in this discussion.
An iftype condition behaved differently inside a lambda or object literal than it does at method scope, in two ways that are now fixed.
A condition that narrows a type parameter and then uses that narrowed parameter in the then branch compiled at method scope but was wrongly rejected inside a lambda or object literal. This was most visible with a recursively-constrained trait — for example trait T[X: T[X]] with the condition iftype A <: T[A], which failed with "type argument is outside its constraint" — but it affected any narrowing condition whose narrowed parameter was used in the branch.
A let or var binding in the then branch of such a condition crashed the compiler with an internal assertion failure instead of compiling.
Both now behave inside a lambda or object literal exactly as they do at method scope. The following program previously failed to compile but now works:
trait T[X: T[X] #any]
fun tag m(): X
class val C is T[C]
fun tag m(): C => C.create()
actor Main
new create(env: Env) =>
let f = {[A](x': A) =>
var x = x'
iftype A <: T[A] then
x = x.m()
end}
f[C](C)
Programs containing a class or struct with a U128 or I128 field could crash with a segmentation fault at runtime when compiled in release (optimized) mode, even though the same program ran correctly when compiled with --debug. The crash happened when such an object was used in a way that let the compiler place it on the stack.
These objects are now correctly aligned in optimized builds, and the crash no longer occurs.
We've added arm64 and amd64 builds for Alpine Linux 3.24. We'll be building ponyc releases for it until it stops receiving security updates in 2028. At that point, we'll stop building releases for it.
UDPSocket.set_multicast_interface not setting the interfaceUDPSocket.set_multicast_interface never actually set the interface used for outgoing multicast. It handed the operating system the address of an internal pointer rather than the resolved interface address, so the kernel received meaningless bytes and the socket's multicast interface was left unchanged, on both IPv4 and IPv6.
It now sets the interface correctly. For an IPv4 interface, pass the interface's IPv4 address. For an IPv6 interface, the interface is taken from the scope id of the resolved address, so a scoped address such as "fe80::1%eth0" is needed to select an interface.
UDPSocket.set_multicast_loopback and set_multicast_ttl having no effectUDPSocket.set_multicast_loopback and set_multicast_ttl did not change a socket's IPv4 multicast loopback or TTL behavior. They applied the options at the wrong socket level, so the request either failed or set an unrelated option, leaving the multicast loopback and TTL at their defaults.
Both now take effect for IPv4 multicast.
On Windows, a UDPSocket that failed to start listening — for example because its host address could not be resolved — would crash the entire process immediately after reporting the failure through not_listening. The failure is now reported and your program keeps running, matching the behavior on other platforms.
UDPSocket.set_broadcast a no-op on IPv6 socketsUDPSocket.set_broadcast promises to enable or disable broadcasting from a socket. On IPv4 sockets it does: it sets the SO_BROADCAST socket option to the value you pass. On IPv6 sockets it instead ignored its argument and joined the FF02::1 all-nodes multicast group — set_broadcast(false) joined the group too, and the group was never left.
IPv6 has no broadcast, and sending to a multicast address such as the all-nodes group (DNS.broadcast_ip6) requires no permission, so there is nothing for set_broadcast to enable on an IPv6 socket. It is now a documented no-op on IPv6 sockets. The removed join had no observable effect in practice — every IPv6 node is automatically a member of the all-nodes group — so existing programs should see no change in behavior. To receive traffic for a multicast group, use multicast_join.
Previously, if a directory inside a package was named like a Pony source file — that is, with a name ending in .pony — ponyc would abort with an out-of-memory error instead of compiling your program. Such directories are now ignored, like any other non-source entry in a package directory, and compilation proceeds normally.
NetAddress.scope() returned the IPv6 scope zone identifier -- the interface index of a scoped address, such as the eth0 in fe80::1%eth0 -- with its bytes reversed on little-endian platforms. An address scoped to interface index 2, for instance, returned 33554432 (0x02000000) instead of 2, making the value useless for identifying the interface.
scope() now returns the zone identifier correctly. Big-endian platforms were unaffected and are unchanged.
A runtime built with runtime_tracing enabled would crash when the actor_behavior tracing category was active without --ponytracingforceactortracing also being set:
program --ponytracingcategories actor_behavior
The crash occurred whenever a message was scheduled from a thread that was not running an actor, such as the ASIO thread delivering a network event or the cycle detector. Programs that perform any I/O or run long enough to trigger cycle detection would reliably crash. Setting --ponytracingforceactortracing all avoided the crash.
This has been fixed. Actor behavior tracing now works without forcing actor tracing on.
Building a small String or Array incrementally — appending or pushing through its first several elements — triggered repeated reallocations and copies, even though every one of those sizes fit within the single block the runtime allocator had already handed out. The collections now record the capacity of the block they were given, so a small String or Array allocates once and does not reallocate again until it genuinely outgrows that block.
A side effect is that space() may report a larger capacity than before for a small collection; the extra capacity is memory the allocator had already reserved, so a program's memory use is unchanged.
Pony programs failed to link on 32-bit ARM Linux systems, such as a Raspberry Pi running a 32-bit OS. They now build and run correctly.
Sometimes the easiest way to use a C library from Pony is a small piece of C: a wrapper that flattens a macro into a function, fixes up a calling convention, or adapts a struct-heavy API into something FFI-friendly. Until now that meant setting up a second build system to compile the C and a use "lib:..." directive to link it.
Now ponyc compiles the C for you. A .c file placed in a package's directory (next to its .pony files) is a C shim: ponyc discovers it, compiles it with an embedded copy of clang, and links the object into your program. No Makefile, no separate compiler invocation, no use "lib:...".
// main.pony
use "cdefine:SHIM_ANSWER=42"
use @shim_answer[I32]()
actor Main
new create(env: Env) =>
env.out.print(@shim_answer().string())
// shim.c, in the same directory
#include <stdint.h>
int32_t shim_answer(void)
{
return SHIM_ANSWER;
}
ponyc's own headers are on the include path by default, resolved relative to the running compiler the same way the standard library is found — so a shim can #include <pony.h> and call runtime APIs without any configuration, and without hard-coding an installation path that breaks on the next toolchain update.
pony.h is the supported header for shims. ponyc's other internal headers that a shim might reach — ponyassert.h (for pony_assert) and the platform.h closure it pulls in — are also on the include path and shipped with an installed ponyc, but they are internal headers offered as a convenience, not a stable interface: they can change between releases without notice. Build against pony.h; reach for the others only if you accept that they may move.
Two new use schemes configure how a package's shims are compiled. use "cdefine:NAME" or use "cdefine:NAME=VALUE" defines a C preprocessor macro (clang's -D), and use "cincludedir:PATH" adds an include search directory (clang's -I), with relative paths resolved against the package's directory. Both apply only to the package that declares them — putting one in a package with no .c files is an error, since it could never take effect; the C source it was meant for is usually in a different package. Both accept guards: use "cdefine:USE_EPOLL" if linux. Defining the same macro name twice in one package is an error, whether or not the values match — platform-specific values belong behind guards, where only the active target's definition counts. The macro name must be a plain C identifier: function-like macros (cdefine:CALLBACK(x)=...), which clang's -D accepts, are rejected — define a regular macro and let the C do the rest. Note that cdefine: is a C preprocessor macro for shims only; it is unrelated to ponyc's own --define/-D flag, which drives Pony's ifdef.
The shim sources themselves have no per-file guard: every .c in the package directory is compiled on every platform. A platform-specific shim wraps its whole body in #ifdef so it compiles to an empty object elsewhere. On macOS, shims resolve system headers through the SDK's usr/include (framework-style includes, clang's -F, are not supported); on Windows, through the installed MSVC and Windows SDK include directories.
Shim objects are linked directly, not archived into a library first. That has two consequences worth knowing. First, C constructors (__attribute__((constructor))) in shims run at program start, like any directly linked object. Second, two shims defining the same symbol fail the link loudly with a duplicate-definition error — and a shim that also defines a symbol from a use "lib:..." library silently wins when that library is a static archive (the common case: the shim object satisfies the symbol, so the archive member is never pulled in). A lib: that names a whole object file or a shared library behaves differently — a duplicate definition in a whole object is a loud link error, and a shared library still loads at runtime — so don't rely on the shadow for those; use the migration below.
That last point is the migration warning: if you previously compiled a .c next to your Pony code into a library by hand and linked it with use "lib:...", ponyc now also compiles that file as a shim, and the shim object silently shadows your hand-built library. Either delete the manual build and the use "lib:..." directive (the shim path replaces both), or move the .c out of the package directory (a subdirectory works — only the package's top directory is scanned).
C compile errors are reported like any other ponyc error, with the file, line, and column. Shims are recompiled on every build; their objects live in the output directory during the build and are cleaned up after a successful link. Under non-link modes (--pass c, --pass obj, and friends) or after a failed link the objects stay behind, and renaming or deleting a shim source can leave an old object in the output directory — they're plain files, safe to delete. Because ponyc now carries clang inside it, the compiler binary is noticeably larger than before.
A C shim is the package doing C, so --safe governs it exactly like a C FFI call: compiling a shim requires the package to be allowed to do C FFI. A .c in a package that isn't on the --safe list is an error, the same one you'd get for an FFI call there. The file is allowed to sit in the package; what's gated is compiling it.
The --ponysuspendthreshold runtime option controls how long a scheduler thread waits, while it has no work to do, before suspending itself to reduce CPU usage. It is documented in milliseconds and defaults to 1 ms.
On the most common platforms, including all x86 systems, the threshold was being applied at half its intended scale, so threads started suspending after about half the idle time you asked for. Passing --ponysuspendthreshold 100 caused threads to begin suspending after roughly 50 ms instead of 100 ms, and the 1 ms default behaved like 0.5 ms.
The threshold now uses the same timing scale as the runtime's other interval options, such as --ponycdinterval, rather than half of it.
As a result, idle scheduler threads now stay awake roughly twice as long before suspending compared to previous releases. Programs that depend on scheduler threads suspending quickly when idle (for example, to keep CPU usage low on an otherwise idle process) may see threads remain active a little longer, while programs sensitive to the cost of waking a suspended thread when new work arrives may benefit. If you previously tuned --ponysuspendthreshold, halve your value to keep the same timing as before.
The same calibration error affected an internal threshold the runtime uses to decide a scheduler thread has gone idle, which doubled along with the suspend threshold. As a side effect, a program that goes idle may take a fraction of a millisecond longer to be detected as ready to terminate than in previous releases.
The runtime scheduler makes several decisions based on how much time has elapsed, measured with a CPU cycle counter. Two of those calculations were wrong.
On 32-bit ARM, the counter is a hardware cycle counter that periodically wraps back to zero. Several scheduler checks subtracted two readings without accounting for the wrap, so for one iteration after each wrap the elapsed time came out as a nonsensical huge value — briefly suspending a scheduler thread early, sending an internal "no more work" notification early, or printing runtime statistics ahead of schedule before self-correcting. The runtime now accounts for the wrap, so the scheduler no longer misreads elapsed time as the counter wraps.
Separately, on every platform, the runtime statistics interval (available when the runtime is built with stats tracking and selected with the stats-interval option) was computed at the wrong scale and at too narrow an integer width: statistics printed roughly a thousand times more often than the requested interval, and a large interval could overflow to a small value. Statistics now print at the requested interval.
Programs that run on other platforms and don't enable runtime statistics were unaffected.
On 32-bit ARM, a scheduler thread that had been idle (suspended) for longer than a couple of minutes could, on waking, briefly burn CPU before suspending again instead of re-suspending promptly. This happened because the CPU cycle counter the scheduler uses to measure idle time wraps during such a long idle. Idle scheduler threads on 32-bit ARM now re-suspend promptly no matter how long they have been idle. Programs on other platforms were unaffected.
On Windows, a UDP datagram larger than the receiving socket's read buffer was delivered to UDPSocket's notifier as an empty array, silently dropping the entire datagram. On Linux, macOS, and the BSDs the same datagram is delivered truncated to the buffer's first bytes, with only the excess discarded.
Windows now matches that behavior: an oversized datagram is delivered truncated to the buffer's first bytes instead of being dropped entirely. This does not prevent data loss -- the bytes beyond the buffer are still discarded, on every platform. To receive a datagram whole, size the UDPSocket read buffer (the size argument to the listen constructors) to your protocol's largest expected datagram.
The compiler could crash when compiling a very long sequence of expressions, such as a large array or tuple literal, a long argument list, or a long function or method body — the kind of thing code generators often produce. Sequences of this kind now compile without crashing, regardless of how many expressions they contain.
We've moved our supported FreeBSD 15 release from 15.0 to 15.1. ponyc is now built and tested against FreeBSD 15.1.
Systematic testing (use=systematic_testing) lets you replay a runtime interleaving from a seed: run until an intermittent bug shows up, then re-run with --ponysystematictestingseed <seed> to replay the same interleaving and chase it down. Previously this only held with a single scheduler thread (--ponymaxthreads 1), and a single thread explores no interleavings at all, so reproducibility and interleaving exploration were mutually exclusive.
With scheduler scaling disabled (--ponynoscale), a fixed seed now replays the same interleaving across runs with more than one scheduler thread, while different seeds still produce different interleavings. The cycle detector can stay enabled. Making dynamic scheduler scaling itself reproducible under systematic testing is not yet covered; use --ponynoscale for reproducible multi-threaded replay until then.
Under use=systematic_testing, replaying a run from a fixed --ponysystematictestingseed is meant to reproduce the same scheduler interleaving. Previously this could fail for programs with enough actor-to-actor messaging to trigger backpressure: the replay depended on the process's memory layout, so address-space layout randomization (ASLR) made the same seed produce different interleavings from one run to the next, even with --ponynoscale and a fixed thread count. A fixed seed now replays the same interleaving regardless of memory layout.
Under use=systematic_testing, replaying a run from a fixed --ponysystematictestingseed is meant to reproduce the same scheduler interleaving. For workloads that pass references between actors — anything that drives the runtime's reference-counting (acquire/release) messages — a fixed seed could still replay a different interleaving from one run to the next, because those messages were sent in an order that depended on actor memory addresses, which the operating system randomizes per run. Replaying such a workload now reproduces the same interleaving regardless of address-space layout.
This builds on the earlier fix for the actor muting path. With both in place and the cycle detector disabled (--ponynoblock), a fixed seed replays deterministically for these workloads; the cycle detector has its own remaining layout dependence, tracked separately.
Programs run under systematic testing (--ponysystematictestingseed) could
intermittently hang instead of running to completion. The hang was timing
sensitive: the same seed might hang on one run and finish normally on another.
This has been fixed.
The runtime's systematic testing mode can now be built and run on Windows. Previously it was only available on Linux and macOS: the Windows build script had no way to turn on runtime use options, and the systematic testing runtime had never been compiled for Windows.
Enable it by passing -Use systematic_testing to make.ps1 configure. Unlike the Linux and macOS builds, Windows does not pair it with scheduler_scaling_pthreads — it is enabled on its own, because Windows scales the scheduler with native primitives rather than pthreads.
.\make.ps1 configure -Config Debug -Use systematic_testing
.\make.ps1 build -Config Debug
A program built this way replays a single scheduler interleaving from a fixed --ponysystematictestingseed, the same as on the other platforms.
Under use=systematic_testing, replaying a run from a fixed --ponysystematictestingseed is meant to reproduce the same scheduler interleaving. Any workload that exercised the cycle detector — actors blocking and unblocking, and reference cycles forming and being reclaimed — could still replay a different interleaving from one run to the next, because the detector issued its messages (the probes it sends while checking which actors have blocked, the confirmations it sends to a cycle's members, and the reference-count releases it sends when reclaiming a cycle) in an order that depended on actor memory addresses, which the operating system randomizes per run. Replaying such a workload now reproduces the same interleaving regardless of address-space layout.