Repository navigation
Coming from 0.3.0, read three groups of entries first.
What stops code compiling. A record field is private to its module
unless it is marked pub. Glob imports and a second with or raises
clause are removed. A bare case name in a pattern, a constructor pattern of
the wrong type and two instantiations of one error type in a row are
refused. A Region, a Scope and a Db stay on the fiber that opened
them, and a spawned fiber's error must be Share. Fiber::cancelled is
spelled Fiber::canceled. A handler for Db needs query_each, depth,
savepoint, release and rollback_to. A khora.toml that does not load
stops check, build and the other project commands, and three shapes
0.3.0 warned about are refused.
Wrong answers given with nothing to say so, fixed, so code that ran on
0.3.0 may behave differently: a postgres query could return another
caller's rows; std::db::transaction could lose writes it had answered
Ok; a transaction inside another one committed or rolled back the outer
one; a postgres transaction whose body ignored a failed statement was
answered Ok with nothing committed; the type checker let through a pattern
of the wrong type, a catch that yielded 0 and a raise that no catch
saw; a case name written bare in a pattern matched every value; a catch
arm could read a generic error's field at the wrong type; and a lambda
handed to scoped inside a function with a scope of its own released what
it acquired at the outer scope's end.
Crashes a server operator meets, each of them in 0.3.0: every Router
server died after about 130,000 connections; one client that reset its
connection killed the whole server with SIGPIPE; the otlp exporter
crashed every traced service after about 20,000 spans; a 31 KB request body
of nested brackets, or other long input, ran the stack out and ended the
server; and a fiber on the scheduler backend had an eighth of main's
stack.
Cancellation has its own channel. A canceled fiber stops at its next
cancellation point in every function, whatever the function's raises row,
which changes what SIGTERM, Fiber::join, Fiber::outcome and catch do
in code with no row. On the TechEmpower tests Khora serves more requests a
second than Go on JSON, as many on Fortunes, and fewer on the two database
tests (see Changed).
Breaking
-
A
postgresquery could return another caller's rows (see Fixed). A
program that ran with a receive deadline, or on a network that dropped
reads, may have acted on answers that belonged to other queries. The
connection that caused it is closed instead of reused, and its pool slot
reconnects it (see the pool entry below). -
std::db::transactioncould lose a write it had acknowledged withOk
(see Fixed). Atransactionwhose fiber is canceled before its body
finishes is rolled back, including one canceled whileCOMMITwaited for
its reply, which changes nothing if the commit ran. ADbhandler has a
rule to follow for it; see theDbhandler entry below. -
A
transactioninside atransactioncommitted or rolled back the
enclosing one (see Fixed). An innertransactionis a savepoint: its
writes are committed with the enclosing transaction's or not at all, and
its failure undoes only its own writes. -
A
postgrestransaction whose body ignored a failed statement was
answeredOk(see Fixed). It is answeredDbError::RolledBack. -
A bare name in a pattern that is the name of one of its value's cases is
refused, inmatch,catch,letandfor, at any depth, and for
cases with a payload as well as without.Red => ..over aColorbinds
every color; the message names the pattern to write,Color::Red
(FsError::NotFound(_)for a payload case, and the type's module where
the file never imported it). A bare name that is a type's only case, as
incatch { Stop => () }over a recordStop, matched what the case
would have and is refused as well: write_. The check is against the
value's type, so a case added to a type later makes a binding of the same
name an error rather than a match. A capitalized bare name that is no case
of the value's type is refused as well: a name in a pattern that starts with
a capital letter must be a case. The message reads like an undefined
name and offers the nearest case, "Colorhas no caseGren. Did you
meanColor::Green?", when one is within two edits (or a third of the
name, if that is more); the language server offers it as a quick fix. A
constwritten as a pattern (FAVORITE => .., which binds rather than
compares) is told to use a guard,n if n == FAVORITE. Bind with a
lower-case name. 0.3.0 built all of these with anunused-binding
warning at most, and gave a wrong answer (see Fixed). -
A constructor pattern of one type, matched against a value of another,
is refused, at any depth, inmatch,catch,letand a pattern inside
a tuple or record pattern: "this pattern is aResultcase, and the value
here is aBig". This includes a pattern over a type parameter ("... the
value here is aT"). Where both types were held on the heap, 0.3.0 built
the program and read the field out of the wrong constructor (see Fixed). -
(raise X) catch { X => 7 }is anInt, the type its arms produce.
0.3.0 typed it as the operand's, which never finishes, so it fitted
wherever a value was wanted and yielded 0 there (see Fixed). A use that
fitted only because of that is refused:
let s: String = (raise X) catch { X => 7 }. -
A
raiseof a value whose type is worked out later is charged to the
catches and the row around it, as araiseof a value of known type
is. On 0.3.0 such an error went past everycatch(see Fixed). Programs
this refuses:- the error escapes a closure's
catchand the closure is called without
!: "kcan leave this function, so the call needs!"; - the value turns out to be a second instantiation of an error type the
catchbeside it names: "acatcharm handles one instantiation of a
type, and this operand raises two; catch them with_, or in two
catches". A program of this shape whose other branch was the one that
ran happened to give the right answer, and is refused all the same; - the value's type is never worked out at all: "the type of this raised
value was never worked out"; - the value turns out to be an error type that the closure's row, already
fixed by where the closure is passed (attempt,retry, a parameter
written() -> Int raises Nf), does not carry: "thisraisesendsDn,
and the closure it is in had its error row closed to{ Nf: Nf }, which
does not carry it", naming where the row was fixed and suggesting an
annotation. Another closure calling this one beside aDnof its own
does not count: the row has to carry it.
0.3.0 built these and crashed, or, where the raising branch never ran or
the error happened to reach acatchthat named it, gave the right answer. - the error escapes a closure's
-
An error type appears in a
raisesrow at one instantiation. An arm
that reads a field of a generic error at a type other than the one raised
is refused; on 0.3.0 it read the wrong type (see Fixed). So is anything
that would put two instantiations of one error type in one row, wherever
they meet:- one
catchover(if b { fi()! } else { fs()! }), withfiraising
Gx<Int>andfsraisingGx<String>, when it has aGx::..arm or an
arm that binds the failure (catch { e => .. }), including a_beside
aGx::..arm; - a closure whose body raises both, however it is then used: called,
passed toattemptor a row-polymorphic function, or given to
Fiber::spawn; raises E + Finstantiated withEandFtwo instantiations of one
type;- two different error types that share a name, declared in two modules
(anm::a::Eand anm::b::E), wherever the same routes put them in
one row. An error row is labeled by the type's bare name, so the two
collide as two instantiations do; a named arm there read one type's
field as the other's on 0.3.0. The message says they are two types of
one name.
One arm reads one layout, and the program cannot say which instantiation
arrived. Catch each where it is raised, in acatcharound each call, or
with a_arm alone. - one
-
A lambda handed to
scopedornurseryinside a function with a
scopeornurseryof its own used the enclosing one (silent wrong
answer). Infn f() with { scope: Scope },scoped(fn () => work())gave
workthescopeoff, not the onescopedopens, so whatwork
acquired was released whenf's caller's scope ended rather than when
thatscopeddid. The capabilityscopedornurseryhands its lambda
is the lambda's own, and a binding of that label outside the lambda does
not shadow it. A capability the lambda writes by name,scope.defer(..),
is the binding of that name in scope where the lambda is written, as any
name is. -
On the scheduler backend, a finalizer could be skipped (see Fixed). A
program onKHORA_FIBERS=schedulerwhose cleanup was silently skipped
under load runs it. -
A record field is private to the module that declares its type, unless
it is markedpub. Outside that module a private field cannot be read,
assigned, named in a literal or an update, or bound in a pattern, and a
value with any private field can only be built by its own module. A
wrapper's value follows the same rule:type Port = Int;can be built with
Port(n)and opened withmatch p { Port(n) => .. }only in its module,
andtype UserId = pub Int;opens one to everybody. A case's payload is
always public.derive(Decode)is refused on apubtype with a private
field, because it would build one from input without calling anything that
checks.pubwritten in a row, on a case's payload or on an effect's
operation is a syntax error, and so ismutwritten in a row, which was
accepted and ignored.The migration. For each error
khora checkreports:- a record other modules read or build, with nothing to keep true (a
request body, a report, a settings record): writepubbefore each
field other modules use, as inpub type Point = { pub x: Int, pub y: Int };. Itsderive(Decode)then compiles unchanged; - a record whose constructor checks something: keep the fields private and
call the constructor and getters from outside. For aderive(Decode)on
one, writeimpl Decode for TwithSchema::try_mapover the checking
function; - an identifier wrapper built from other modules:
type UserId = pub Int;; - a test in a separate
module foo_test;that readsfoo's private
fields: move it intofooas atestblock, which is inside the module,
or test throughfoo's functions.
In
std,Decimal,Map,Vector,Date,Time,Offset,
std::net::http::Connection, the iterator adaptersMapped,Filtered
andTaken, the TLS handles andDict'sLeastkeep their fields
private.Date::year,monthandday,Time::hour,minute,second
andmilli,Offset::minutesandDecimal::scaleread them. Every field
ofstd's other public records ispub. Inpostgres,Connection's
fields are private;transaction_depthandset_transaction_depthin
postgres::connread and write the one piece of statepostgres::db
keeps there.postgres::pool'sPoolkeeps its fields private; its
HealthandReconnectrecords have every fieldpub, and
idle_countcounts the connections waiting to be lent.A record update,
{ ..base, field: value }, whose base is a type
parameter is refused: a type parameter is not a record, whatever it is
instantiated at. One whose base is an unannotated lambda parameter is
checked once the parameter's type is known, with the same rules as any
other update, and one whose base's type is never known asks for an
annotation. A value there that needs the field's type to be typed -- an
integer literal for aU8field -- needs an annotation, because the
field's type is not known yet when the value is. On 0.3.0 both kinds
type-checked with none of their fields checked, so a field that did not
exist, or a value of the wrong type, compiled. - a record other modules read or build, with nothing to keep true (a
-
A
RegionorScopestays on the fiber that opened it. Neither is
Share: a fiber's body cannot capture one, a channel cannot carry one, a
Sharedcell cannot hold one, a fiber cannot answer or raise one, and a
handler for another effect cannot capture ascope. So every finalizer runs
on the fiber that deferred it. On 0.3.0 a child could defer a finalizer
over amutrecord into its parent's scope, and the parent's release could
run it while the child was still writing the record: a data race on its
fields, and a use-after-free when the child replaced a field the finalizer
was reading.Region::root()andScope::root()belong to the program's
own fiber, and a spawned fiber that calls either stops with a fatal error
(exit 134). Atestorbenchblock has a root region of its own,
released when that block ends. A region whose last reference goes on a
fiber that did not open it stops the program with a fatal error too,
rather than running its finalizers there.The migration is one line: a child that acquires something is given a
scope of its own,Fiber::spawn(fn () => scoped(work)), instead of being
handed its parent's. A lambda works as well,
scoped(fn () => work(connection)), and so does a named function whose
body callsscoped. What it acquires is released when the child'sscoped
ends. A resource that has to outlive the child is a shareable one (a pool,
a channel, aSharedcell): acquire it in the parent and hand the value
in. The refusal names the fix. -
A
Dbstays on the fiber it was installed on. A fiber spawned inside
awith_dbbody cannot use that body'sdb; it takes a lease of its own,
Fiber::spawn(fn () => with_db(pool, work)), and the refusal says so, at
the use inside the fiber and with the line of the spawn. ADbcannot
travel through a channel, aSharedcell, a certified closure or another
effect's handler either. In exchange aDbhandler may capture a record
withmutfields, which is what lets a pool lend the borrower its
connection. The rule is by name, so a module's own type calledDbis
held to it too. -
A spawned fiber's error must be
Share, as its answer must. Every error
in theraisesrow of a body handed toFiber::spawnis held toShare,
so raising a record with amutfield, anArrayor aMapout of a
fiber is refused at the spawn.joinandoutcomehand the raised error
to whoever joins, and two fibers could join one handle: one wrote the
error's fields while the other read them.Fiber::outcomeand
nursery.adopttake the same handle, so the spawn covers them. A
SharedFn::ofclosure's error is not held to it, because it is raised on
the caller's own fiber, and neither is atestblock's. The
migration: raise an error withoutmutfields, or with the data the
joiner needs copied into immutable fields; aRegionorScopein an
error is refused as before, with the message that namesscoped. The
error is judged by its own declaration, also when the spawning file never
imports it: a body raisingChildFailedthroughnurserycrosses without
ChildFailedbeing named. For that, a file sees the declarations of the
types an imported function's signature names, as it sees those an imported
type's fields name. It sees them only to judge them; they are not in scope. -
A capture whose type is settled after
Fiber::spawnorSharedFn::of,
or after thehandler forthat captures it, is held toShare. A
program where one such capture ends up a record with amutfield, or a
Region, compiled and shared it between fibers; it is refused (see
Fixed), as the same capture is when its type is known at the spawn. -
Every function can be canceled, whatever its
raisesrow. A loop, a
recursive call, a blocking operation or a call to a function that reaches
one is a cancellation point in a function with no row, as it always was in
one with a row. On 0.3.0 a fiber with no row ran to its end after a cancel;
it stops, runs its finalizers, and is reported stopped:
Fiber::canceledanswerstrueandFiber::outcomeanswersStopped
for such a child, where 0.3.0 answeredfalseandAnsweredwith whatever
it had computed so far. Amainwith no row that reaches a cancellation
point is stopped by the firstSIGTERMorSIGINT, runs its finalizers
and exits 130; on 0.3.0 it died at once, with status 143 and no
finalizers. -
Fiber::joinon a stopped child stops the joiner, whatever the child's
row. On 0.3.0 a child with no row handed its joiner an answer it never
computed. UseFiber::outcometo get an answer if there is one without
being stopped, orFiber::waitto wait without asking for one. -
catchnever sees a cancellation,_arm included, in any function.
On 0.3.0 a totalcatchin a function with no row either handed back a
zero nobody computed or, where the answer was a boxed value, ended the
process with status 134. The cancellation passes through, finalizers run,
and the fiber stops. -
A fiber canceled before it starts does not run. Its body checks for a
cancellation as it is entered, so none of its statements run. -
A
Shared::updateormodifychange function that is stopped leaves the
cell unchanged. A change function runs to its end after a cancel. One
that comes back stopped, because aFiber::join,waitoroutcome
inside it was stopped, stores nothing; 0.3.0 stored a zero or a null. -
Channel::sendandChannel::receiveloseraises 'er. A closed
channel is already theOptionorBoolthey return, and the row existed
only to give a cancellation somewhere to go. A call needs no!--
Channel::receive(jobs)-- and a leftover one is accepted. -
Names are spelled in US English. Every public name with a British
spelling is renamed, with no alias for the old spelling:Fiber::cancelled→Fiber::canceled(std::core);khora release --noteswrites its section as## Behavior changes
(was## Behaviour changes).
Diagnostics, documentation and the standard library's doc comments use
US spelling throughout: color, behavior, canceled, initialize, recognize,
analyze, license. No manifest key, lint name, CLI flag, environment
variable or diagnostic code had a British spelling, so none is renamed. -
A
std::db::Dbhandler has five more operations, and itsrollback
must accept being called when no transaction is open. A
handler for Db { .. }written for 0.3.0 does not compile until it has
query_each,depth,savepoint,releaseandrollback_to, and the
error names the gap.query_each(sql, sets)runs one statement once per set of values and
answers what callingqueryonce per set would, in order, one answer
per set. A handler over a driver that can pipeline writes every set
before it reads a reply, so twenty lookups are one exchange with the
server instead of twenty; thepostgrespackage's handler does, with a
Syncafter each set, so each set runs in its own implicit transaction
and fails alone. A handler that cannot pipeline calls its ownquery
once per set:
query_each: fn (sql, sets) => List::map(sets, fn values => my_query(sql, values)).- A handler keeps how many transactions are open on its connection and
answersdepthwith it;transactionopens a savepoint instead of a
transaction when it is above 0.rollback_tois called for a savepoint
that was never opened when a cancel lands beforeSAVEPOINTwent out,
and must answerOkwithout undoing anything then. A handler that does
not support nesting still answersdepthtruthfully (1 betweenbegin
and itscommitorrollback, 0 otherwise) and refusessavepoint; a
nestedtransactionis then answered with that refusal instead of
opening a second transaction. transactionregisters its rollback before it sendsBEGIN, so a
cancel that lands before theBEGINgoes out, or whileCOMMITwaits
for its reply, is followed by aROLLBACK. A handler must answerOk
then; one that answersErris toldbroken, and a pool built on it
drops the connection. PostgreSQL answers a strayROLLBACKwith a
warning, which thepostgrespackage reads asOk. The rollback of a
body that failed runs as cleanup, which a plain cancel does not
interrupt, so a handler whose rollback blocks is waited for.
-
A
postgrespool reconnects the connections it loses, checks each one
before lending it, and lends the connection itself. A connection whose
reply was cut off, thatstd::dbcalledbroken, that the server closed
while it sat idle, or that anabortleft inside a transaction, is closed
and reconnected by its slot, with exponential backoff from 50 ms to a 5 s
cap, with jitter, for up to 30 s. Callers wait for it rather than being
lent it. A connection that opens and then fails its check before it is
ever lent counts as a failed attempt on the same schedule, so a server
that admits connections and spoils each one is retried on the backoff and
not as fast as a slot can connect. The 30 s counts time spent
reconnecting, not the time a connection sat open: one left idle for
longer and then closed by a server restart is retried on the backoff like
any other. A connection that cannot be reconnected in that time leaves the
pool and retries every 30 s, and rejoins when it connects. A pool with no
connection live or reconnecting answerswith_dbat once with
Disconnectedand the reason the last attempt failed, including callers
that were already waiting. In 0.3.0 a connection the server closed stayed
in the pool and every lease on it was answeredDisconnecteduntil the
pool closed, and a connection that never opened answered every request
with the reason.with_dbreceives a connection over aHandoff, checks it on the
borrower's fiber (one the server reset while it sat idle, one mid-reply
and one inside a transaction are never lent; one the server sent a
notification, a notice or a parameter change is lent as it is), and
installspostgres::db::direct(c), aDbthat writes the socket and
reads the reply on the borrower's own fiber. A statement costs no channel
round trip and no fiber switch. The connection goes back whenwith_db
returns, however it returns, and every reply still arriving on it -- one
per set of aquery_eachbatch whose borrower was canceled part-way -- is
read by its slot before it is lent again.serveandoverkeep the
serving-fiberDbfor one connection shared by several fibers.opentherefore returns a pool that lends nothing until a connection
opens.open_withtakes aReconnectplan: itsfastschedule, its
slowinterval (Noneto stay out for good), and ahandshakebound in
milliseconds (10 s by default), after which a server that accepted the
connection and has not finished the startup exchange counts as a failed
attempt.Reconnect::never()makes one attempt and no retry.health
reports how many connections are live, reconnecting and down.
postgres::db::Workhas a case for each newDboperation, so code that
matches on it must follow. -
Response::jsonwrites a derived record's keys in the order the type
declares them, not sorted. It built aJsonobject, a hash table, and
sorted its keys on every call; it writes the record straight to text, so
{ zebra: Int, apple: String }goes out as{"zebra":..,"apple":..}and a
Rejectionas{"path":..,"message":..}. Declaration order is the one the
author chose and costs nothing to keep. AMapand aJsonobject are
written sorted by key, as is anything throughRaw::to_json.Encode
gainsencode_json, with a default, so hand-written impls compile
unchanged. A two-field record took 51 allocations to encode and takes 9. -
std::json::parserefuses a document nested deeper than 512 levels.
A document of 513 levels or more parsed when the stack held it (about
15,000 levels in a debug build on an eight-megabyte stack) and is a
JsonErrorwhoseexpectedis "at most 512 levels of nesting". Listed
here because a program that read such documents gets an error for them:
parse_with_depth(text, limit)takes another limit, at about 620 bytes of
stack a level in a debug build and 200 in a release one. See Fixed for why. -
std::net::socket::receive_nowanswers-1only when nothing has
arrived yet, and a lower number when the read failed. A caller that
treated every negative as "nothing yet" can tell a reset connection from a
quiet one withnothing_yet; a caller that stops on any negative is
unaffected. -
Glob imports are removed.
import a::b::*;is a syntax error. Name
what the file uses:import a::b::{X, Y};. -
At most one
withclause and oneraisesclause,withfirst.
raises A raises B,with {..} with {..}andraises E with {..}are
syntax errors, in a signature and in a function type. 0.3.0 accepted a
second clause and ignored it, which surfaced as an error about a correct
line elsewhere (araise"not raised", a capability "not in scope").
Write one row,with { a: A, b: B }, and one union,raises A + B, in
that order. -
A name imported twice is refused, with both imports named:
import a::{f}; import b::{f};or twoasaliases that give one name.
0.3.0 used whichever import came first, so swapping twoimportlines
changed which function the program called. Keep one import, or rename one
withas. The same item imported twice is not an error. -
A
khora.tomlthat does not load stopscheck,build,run,test,
benchandfmt, with the file, line and key that are wrong. On 0.3.0,
given a path to a project from outside it, these commands went on with the
default lint levels and formatter settings, so one bad[lints]entry --
a level spelled"loud", a table withoutlevel-- turned everydeny
beside it into awarn, andkhora checkpassed the program the manifest
said must fail. Fix the entry the message names. The language server keeps
running on the defaults, and reports the manifest as an error on the line
to fix. -
Three
khora.tomlshapes that 0.3.0 warned about and ran are refused,
because lint groups give them a meaning. Each stopscheck,build,
run,testandbench, naming the file and the key:- a lint group written as a string,
idiomatic = "warn"under[lints].
A group is always a table: write[lints.idiomatic], with
level = "warn"under it if you want one level for every lint in it; - a group named for a package,
"acme::strict", as a string or as a
table under[lints]. Groups published in packages are not supported
yet: copy the group file into the project and declare it under
[lint-groups]; - a
[lint-groups]entry whose file is missing or does not parse. 0.3.0
ignored the whole table as an unrecognized key. Point the entry at the
group file, or delete it.
- a lint group written as a string,
-
A
withclause that names two instantiations of one capability type
under the type's own name (with Box<Int> + Box<String>) is refused.
0.3.0 kept the first and dropped the second without a word.
Fixed
-
khora release --minorand--patchrefused a workspace whose toolchain
pin matched its package version. Both fields carried the sameversion =
text, so the writer found two matches and stopped. It selects only the
[workspace.package]entry and leaves the toolchain pin untouched; the
workspace must update its pin separately when adopting the new compiler. -
On the scheduler backend, fibers sharing a
Sharedcell could hang. A fiber
waiting for the cell blocked its worker thread while the lock holder needed
that worker to run. A long change function preempted while holding the cell
could hang on one, two or four CPUs when enough fibers contended; a change
function waiting on a channel could hang on one CPU. Cell waiters give their
workers back instead.Shared::updateandShared::modifyalso stop waiting
when canceled, without running the change function. Threads not running as
scheduler fibers still wait on their own threads. 0.3.0 had the same hang. -
A delayed cell wake could shorten a shielded cleanup sleep. If a lock
release chose a waiting fiber just as it was canceled, the wake could arrive
after that fiber began a later sleep and end it before the deadline. A sleep
checks its absolute deadline after every wake; ordinary cancellation still
ends an unshielded sleep promptly. -
A
postgresquery could return another caller's rows. When a read
failed partway through a reply, the caller was toldDisconnected, but the
connection went back into service with the rest of that reply still
arriving on it. The next query on the connection read the old reply as its
own, and every query after it got the answer to the one before: in one
soak, 100 of 300 answers were wrong. In 0.3.0 this needs a read to fail in
the middle of a reply, on either fiber backend: a receive deadline shorter
than a slow reply is enough, and so is any other failed receive. A
connection whose reply is cut off is closed and never used again, and its
caller and anybody queued on it getDisconnected. -
std::db::transactioncould lose a database write it had acknowledged
withOk. A fiber canceled just aftertransactionsentBEGIN(while
it waited for the server's answer, or on its way into the body) stopped
without rolling back, so its connection went back to thepostgrespool
inside an open transaction. The next caller to borrow that connection ran
its statements inside the leftover transaction: an autocommitinsertwas
answeredOk, then rolled back when that transaction ended, and the row
was gone. Against a real server, with fibers canceled 1 to 4 ms into a
loop of transactions, 0.3.0 left the connection inside a transaction after
about half of the cancels, on both fiber backends, and lost acknowledged
writes in every run.transactionregisters its rollback before it sends
BEGIN. A cancel that lands whileCOMMITwaits for its reply also rolls
back, which changes nothing if the commit ran and ends the transaction if
it did not, and the rollback of a failed body runs as cleanup, so a cancel
cannot cut it short. -
A
transactioninside atransactiongave answers that disagreed with
what was committed. Both sentBEGIN, which PostgreSQL only warns about
inside a transaction, so the innerCOMMITcommitted the outer body's
writes and the innerROLLBACKundid them. An outer body that failed after
an inner one succeeded was answeredErrwith every row committed; an
outer body that carried on after an inner one failed was answeredOk
with its own earlier writes gone. An innertransactionis a savepoint: its
Okkeeps its writes for the enclosing transaction to commit, and its
Err, a raise or a cancel undoes only its own writes, after which the
enclosing body can carry on and commit. A cancel in an inner body undoes
the inner savepoint and then the enclosing transaction, and so does
Fiber::abort. Two fibers sharing onepostgreslease that run
transactionat the same time could have a row committed for a
transactionthat answeredErr; the driver refuses an operation for a
level its connection is not at, so one of them is answeredErrand
nothing it wrote is kept. Fibers sharing a lease should not do this. -
A
postgrestransaction was answeredOkwhen nothing had been
committed. A body that ran a statement which failed, ignored the
failure and returnedOkhad itsCOMMITanswered by PostgreSQL with a
rollback and no error, and the driver reported that as a commit. The
caller is toldRolledBack. Inside an innertransaction, PostgreSQL
refuses the savepoint's release instead; the inner body is told
RolledBack, its writes are undone, and the enclosing body carries on. -
A case name written bare in a pattern matched every value. A bare name
in a pattern binds, somatch c { Color::Blue => "cool", Red => "warm" }
answeredwarmforColor::Green. Unless an arm after it was left
unreachable, the only thing said was thatRedwas never read, and that
went away once the arm read it. Acatchof the same shape,
load(n)! catch { LoadError::Broken(_) => 1, Missing => 2 }, built a
binary that died withIllegal instruction. Both are refused at the name
(see Breaking). -
A constructor pattern was never checked against the type of the value it
matched. In 0.3.0,match o { Option::Some(Result::Ok(v)) => .., _ => .. }
over anOption<Big>built, ran, and readvfrom theSome's layout,
printing an answer with nothing to say it was wrong; so did the same
pattern inside an error'scatcharm (Ng::A(Option::Some(Result::Ok(v)))
overtype Ng = | A(v: Option<Big>)). Where the value was anInt--
match 3 { Option::Some(v) => v, _ => 0 }, or anOptionpattern over an
Intfield of an error -- and for a generic error's arm, the compiler
itself crashed. Each is refused, with the two types named (see Breaking). -
A
catchwhose operand always raises yielded 0. In 0.3.0,
(raise F::W) catch { F::W => 7, .. }used as a function's result or an
argument gave 0, and aletof it was refused; with anifaround the
raiseit gave 7. It gives what the arm produces, in every position,
including a block that ends in araise, a nestedcatch, and araise
in every branch of aniformatch. -
An error raised through a value whose type was worked out later escaped
everycatch. In 0.3.0,raise ewithea closure's parameter, beside
a call thecatchhandled, was charged to nothing: the error went past the
catch, and the program ended with status 130 and nothing printed, or with
"khora: the stack ran out" and status 139. The raise is charged like any
other, so thecatchhandles it or the closure's row carries it to the
caller. -
A
catcharm could read an error's field at the wrong type. Over an
errorGx<Int>, the armGx::X(s, v) => String::byte_length(v)compiled
and read theIntas aString, ending the process with a segmentation
fault. The arm's fields have the types of what was raised, and that arm is
refused. The same held for an arm that binds the failure (catch { e => .. })
and forattempt, over an operand that raised two instantiations of one
error type: one was read at the other's layout, printing a pointer as a
number or ending with a segmentation fault. Those operands are refused (see
Breaking). -
A lambda handed to
scopedinside a function with ascopeof its own
released what it acquired at the outer scope's end (see Breaking).
scoped(fn () => work())there released when the enclosing function's
caller's scope ended, after the line followingscoped. It releases when
thatscopedends, asscoped(work)does, and
Fiber::spawn(fn () => scoped(fn () => work()))compiles there and
releases on the child. -
A fiber could capture a
mutrecord through a type settled after the
spawn. In
let go = fn x => Fiber::spawn(fn () => { let _k = x; 3 }); go(h),
x's type was not yet known at the spawn and passed theSharecheck;
go(h)then made it a record with amutfield, and the child and the
parent shared it with nothing said. The same held forSharedFn::of, and
for amutbinding captured while it still heldList::Nil, and for a
handler foroperation's captures:fn x => handler for Log { record: fn (..) => { let _k = x; () } }, called with amutrecord, gave a
shareable handler holding it, which a spawned fiber then logged through.
Every capture is asked again once the function's types are settled, and
refused with the message a capture known at the spawn, or at the
handler, gets. A capture whose type nothing ever settles has no value to
cross;khora buildrefuses the program because the closure's type was
never pinned down. -
On the scheduler backend, a finalizer that blocked kept the next fiber's
finalizers from running. A fiber whose cleanup was parked (areceive
nobody answers, say) left its worker's release queue open, and every
release the next fiber on that worker made waited behind it forever: its
own finalizers never ran, a canceled parent's child was never told to
stop, and none of it was freed. The fiber still reported that it had
finished. On the thread backend, a region released inside a finalizer
waited for that finalizer to return. -
A
Routerserver died of a segmentation fault after about 130,000
connections.Router::listen,listen_quietly,listen_tls,
serve_foreverandserve_securedtook each next connection by a call that
kept its stack frame, so a server on an 8 MB main stack crashed after
roughly 130,000 connections over its life, however few were open at once
and on either fiber backend, with nothing on stderr. For a busy service
whose clients do not keep connections alive, that is minutes. The accept
loops are loops: a server accepts any number of connections. -
One client that reset its connection could kill a whole Khora server.
After a peer's RST (a killed browser tab, a load balancer,kill -9), the
server's next write to that connection raisedSIGPIPE, whose default
action ended the process on Linux and macOS, on both fiber backends. Every
other client lost its connection with it. A write to a reset connection
fails like any other failed write:std::net::socket::transmitreturns a
negative number, and the server goes on serving. Only sockets are
affected: a program whose output is piped intoheadstill stops on
SIGPIPE, as Unix programs do. -
Long input ran the stack out in
stdand in the OpenTelemetry
exporter. Each function below took its next item by calling itself, Khora
does not promise tail calls, and so each kept a stack frame per item for as
long as its input ran. On the default eight-megabyte stack, which is also a
fiber's:std::fs:fold_chunksandfold_linesended the process on a file of
about 4 GB;fold_lineson a 64 KiB stretch of 40,000 short lines, and
there it also held memory in the square of the line count -- about 2 GB
for a chunk of blank lines;read_diron a directory of 52,000 entries;
file_name,parent,extensionandstemon an 88 KB path.Env::argumentswith 112,000 arguments.std::json:parseon 130,000 bytes of whitespace or digits in a row,
an array of 52,000 elements or an object of 37,000 members;encodeon an
array of 75,000 or an object of 47,000;quoteon a string with 65,000
characters to escape. A router raised toRouter::holding(1048576)
accepts any of these in one request.std::net::http::matches, and so every routed request, on a path of
87,000/-- 87 KB, which such a router also accepts.- The permission checks behind every file, environment and network
operation, on a path or host of 87,000-130,000 bytes. String::charsandString::char_lengthon 87,000 and 104,000
characters, andFloat::of_stringon 87,000 digits.- A
Mapwhose keys share one bucket, at about 2,600 such keys under a
256 KB stack: one lookup, insert or growth walked the bucket a frame per
entry. Integer keys that differ only in their top sixteen bits all land
in one bucket. packages/otlp's exporter, after about 20,000 spans: the fiber that
batches them took each report by calling itself, so every traced service
crashed after minutes to hours.
All of them are loops, and each is tested with 20,000 items (10,000
arguments, and 100,000 spans) under a 256 KB stack, where the recursive
version died at between 1,100 and 3,900. Nesting is the one shape left
that takes a frame per level, andparselimits it; see the next entry. -
A JSON document of nothing but
[ended the process.std::json::parse
took a stack frame per level of nesting with no limit, so 15,375[-- 31
KB -- ran an eight-megabyte stack out, and any service that parsed a
request body under aRouter::holdingof 32 KB or more could be stopped by
one request.parserefuses nesting deeper than 512 levels with a
JsonErrorat the bracket that opens level 513, whoseexpectedis "at
most 512 levels of nesting"; a megabyte of[is that error.Raw::of_json,
Raw::to_json,Raw::json_textand derivedDecodewalk whatparse
produced, so the limit covers them.encodeandShow for Jsonhave no
limit: aJsondeeper than 512 is one the program built. -
A fiber could recurse less deeply than
main, and how much less depended
on the backend. A recursive walk of twenty thousand list cells finished in
mainand on a thread fiber and crashed onKHORA_FIBERS=scheduler,
because the stacks were eight, two and one megabytes. A fiber's stack is
eight megabytes on both backends, the same asmainwith a default
ulimit -s; idle fibers cost the same memory as before, because a stack is
committed only as it is used. On Windows an executable's main thread has
eight megabytes too, where it had the linker's default of one. -
Running out of stack off
main's own path ended the process with no
message. "khora: the stack ran out" was printed only for an overflow on
the main thread. An overflow on a fiber or in akhora testblock ended
the process with a bare signal and empty stderr, on Linux and macOS, as
did one on Linux on the main thread while it started a fiber or ran
another program. The last is how a server whose main thread overflowed
died, at every stack size from 256 KB to 8 MB, on the default fiber
backend. Each of these prints the message. The runtime checks for 16 KB
of stack before each thread or process it starts and, when that is not
there, stops there with the message, so a program that had between 2 and
16 KB left at that point stops at it where it survived the start on 0.3.0. -
On the scheduler backend, a fiber reading or writing a file could wait
forever once the blocking pool's queue had filled. File-system calls run
on a bounded pool of threads, and a fiber that finds the pool's queue full
waits for a slot. A waiting fiber woken by something else -- canceled, for
one -- kept its place in the line for slots, so the next slot that came free
went to it rather than to the fiber behind it, and that fiber was never
told. With nothing more queued, it waited with the pool idle. A fiber
gives up its place whenever it is woken, and waits again at the back if
there is still no room. -
On the scheduler backend, a fiber preempted while holding a
Shared
cell's lock could hang the program on a busy pool. Inside
Shared::update's change function, a fiber that yielded at a loop, or
waited on a channel, kept the cell locked and went back onto its own
worker's queue. If the next fiber that worker ran read the same cell, the
worker's thread blocked on the lock, and while every other worker had work
of its own none of them looked at that queue, so the holder never ran again.
A busy worker takes work from a worker that has not started a fiber in
about thirty-one of its own turns, so the holder is found and the cell
released. That needs one worker whose thread is not blocked: with one
worker, or when every worker's thread blocks on the same cell, the program
still hangs. -
On the scheduler backend, a fiber waiting on a
Channelcould lose the
value it was woken for to a fiber that had not waited, over and over. A
send woke a waiting receiver, but another fiber that ran first and called
receivetook the value, and the woken one went back to waiting. A
connection pool's waiters are exactly that line. A value sent to a waiting
receiver is that receiver's; only values beyond those go to whoever asks
first. A receive or poll from a thread that is not a fiber still takes any
value, and the fiber it was meant for waits again. Measured on this
release's TechEmpower server before and after the fix, scheduler backend,
one CPU, 64 connections and 16 database connections:/db's
99th-percentile latency went from 50-97 ms to 3.5-5 ms against a median of
3 ms, with the same throughput, and Fortunes' from 13-18 ms to 6-7 ms. -
A
postgrespool lost a connection when a fiber waiting inwith_dbwas
canceled just as a connection reached it. The connection was handed over,
and the fiber stopped before its return to the pool was arranged, so nothing
gave it back. Each such cancel shrank the pool by one for good; once it was
empty, everywith_dbandPool::closewaited forever. A service whose
request timeouts cancel handlers meets this under load. A borrower stopped
bycancel,abortorcancel_withinanywhere inwith_db-- waiting,
holding the connection, mid-statement, inside a transaction'sROLLBACK--
gives its connection back, on both fiber backends, and a caller stopped
while it was being told the pool is down leaves the next caller to be told
the same. -
A socket write bigger than the socket's send buffer went out only in
part. That is about 2.6 MB on Linux over loopback. A write sent what
fitted in the kernel's buffer and reported that count, and every write in
std::net::sockettook any count that was not negative as the whole
message. Apostgresstatement that size hung: the driver sent the first
part, the server waited for the rest, and the driver waited for the
answer, forever or until a receive deadline ended it with
Disconnected. An HTTP response that size, written to a client that read
it slowly, could be cut short with nothing reported. Both fiber backends.
transmitandtransmit_bytessend every byte or report a failure. -
On the scheduler backend on Linux, a connection could go unanswered
forever after a receive deadline expired. A wait that ended at its deadline
left its socket recorded as watched. When that connection was closed and a
new one was given the same descriptor number, the new connection's data
was never reported, and the fiber waiting for it never woke. An expired
wait is forgotten along with its socket. -
A commit that lost its connection said only "disconnected". The
COMMITmay have run on the server before the connection went, so a caller
that read the error as "not committed" and retried could apply the
transaction twice. The error is stillDbError::Disconnected, and its text
says it is not known whether the transaction committed. -
let p: Pt = {};passedkhora check. An empty record literal where a
record with fields was expected (an annotation, a return type, an argument)
got no error from the checker, the editor orkhora check.khora build
then refused a record held inline with "xwas not given", and built one
held behind a pointer -- any record with aString, aListor more than a
few fields -- with nothing in the missing fields, so the program crashed on
the first read of one. The literal is refused at the{}with "thisPt
is missingx", once per missing field, bycheck,buildand the
language server alike. -
A closure chosen by
if,match,looporcatchcrashed when it was
called, with "the stack ran out", on both fiber backends. In 0.3.0,
let k = if c { k1 } else { k2 }; k(x)built and died at the call,
whateverk1andk2raised, and whether they were lambdas, named
functions or fields of one record. A function whose body ended in such a
choice (fn pick(c: Bool) -> (Int) -> Int { if c { .. } else { .. } })
was refused as a body that "does not produce" its return type, or, with a
raisesrow, built and crashed when called. A choice called on the spot,
passed straight to a function or discarded stoppedkhora builditself
with an internal error. A closure chosen this way and never called was
never freed. The chosen closure is the value of the expression. -
A canceled
clock.sleep,accept,recvorsendran the caller's
next statements. The call came back early, as it should, and the code
after it carried on as though the call had succeeded. The fiber stops at
the call.clock.sleepis woken by a cancellation on the thread backend as
well as the scheduler. -
A cancellation was lost after a nursery's wait and inside a change
function. A cancel that arrived while a nursery was collecting its
children, or while a blocking call insideShared::updateormodifywas
waiting, let the fiber run on to its end and report that it had not been
canceled. -
A bounded nursery's
adopt, waiting for room, could not be canceled.
The fiber waited until an older child finished, then ran on and reported
that it had not been canceled. -
A loop whose only way round was
continuecould not be canceled. In a
function with araisesrow, awhile,looporforthat went round
only bycontinuenever checked for a cancellation, and ran to its end. -
Fiber::outcomecould end the process with "refcount is already zero"
(status 134) on a fiber that can fail whose answer is held inline and owns
a counted field -- a small record holding aListor aString, say. The
answer was freed twice. -
A
Sharedcell holding a record, or an enum laid out flat, whose fields
include aString,Listor other counted value ended the process after it
was read.Shared::get, the valueShared::updatehands back, and the
value a change function is given underupdateormodifyeach left the
reader and the cell sharing one reference, and the process aborted with
"drop of an object whose refcount is already zero" once both let go.
SettingKHORA_UNBOXED=0avoided it. -
Joining twice a fiber that failed with a small error holding a
String
ended the process with "drop of an object whose refcount is already zero"
(status 134), on both fiber backends. Each join has its own copy of the
error. -
A closure raising two instantiations of one error type, caught by
catch { Gx::Y(n) => n, _ => .. }, ended the process with an illegal
instruction (status 132). Such a closure is refused at build time (see
Breaking). -
A function with a
raisesrow whose body produces no value of its
declared type built, and crashed with "the stack ran out" at its first
call.khora buildrefuses it, as it already refused the same body in a
function with no row. -
An argument that left early leaked the arguments evaluated before it.
InList::reverse_onto(acc, load()!), whenloadraised,accwas never
released: every cell of it that nothing else held, on each call, on both
fiber backends. The same happened
withreturn,breakorcontinueinside an argument, with a
cancellation while a later argument ran, and in every shape that evaluates
several operands before using them -- a method call's receiver, a
constructor, a pipe, a closure call, a record, list or tuple literal, an
interpolation,+on strings, a nested call. Every early exit releases the
operands evaluated so far. Evaluation order is unchanged: left to right,
each once. -
A fiber that failed leaked what its error held when nobody joined it,
when it was joined twice, when its error was one of several carrying
variants, and, withKHORA_UNBOXED=0, when it was joined once. A program
that ran work in a fiber of its own and let it fail lost memory on each
failure. A server built onstd::net::http'sRouterdid not. The error
is released with the fiber's handle. -
An error whose type has a type parameter leaked what it held every
time one holding aStringor other counted value was caught, with or
without a fiber:type E<A> = | Bad(v: A, n: Int)raised as an
E<String>left itsStringbehind, and joined twice from a failed fiber
with theStringkept, it ended the process with status 134. Two
instantiations of one such type, anE<Int>and anE<Big>, were
released as though they had one layout. -
Fiber::joinandFiber::outcomeon a handle that is not bound to a
name (Fiber::join(Fiber::spawn(..))) leaked one object perStringor
other counted field of a small record the fiber answered. -
Shared::modifyleaked one object per call for each counted half of
its{ state, result }answer when both halves are small enough to be held
without a heap object: aShared<String>whose change function answers the
old or newString, say. -
A
SharedFnwhose closure captured aStringor other counted value
leaked the capture when it was released. -
Two modules that each declared an error type of the same name failed to
build, with "not valid LLVM IR". Each module's type is its own. -
A named
catcharm over an error such asGx<() -> Int>, or one that
read a field of aGx<Big>, was refused with "the type of this
expression was never worked out". It compiles. -
In a toolchain built from source, a socket read, write or accept on the
scheduler backend could fail although nothing was wrong. This affected
programs linked against the development-profile runtime --khora build
from a source checkout, and the test suites -- and not the released
toolchain, whose runtime is built differently and did not have it. A fiber
that waited for a socket and resumed on a different worker decided whether
to wait again by reading the error number of the worker it had left, so a
read could fail in the middle of a live stream. The error number is read
on the thread that made the call, in every build. -
The editor's "Write it as one interpolated string" assist changed what
a$and a{meeting across a join print. On"$" + "{a}"it offered
"${a}", which prints the value ofawhere the original prints${a}.
The assist writes"\${a}". If you applied it to such a join, check the
line: the program prints the value.
Changed
-
On the TechEmpower tests, Khora serves more requests a second than Go
on JSON, as many on Fortunes, and fewer on the database tests. Khora's requests
a second against Go's, on Khora's scheduler backend, measured on one laptop
in one run, the two servers given the same CPUs and the same database:
JSON 1.14 (40.6k against 35.5k), Fortunes 1.00 (10.9k against 11.0k), one
query 0.74 (13.7k against 18.5k), twenty queries 0.84 (1,614 against
1,928). The 99th-percentile latency is at or below Go's on every test but
one query, 6.7 ms against 6.4. The database tests are a known limit of
this release: a query costs more in thepostgresdriver and pool than
in Go'spgx, and that is the next performance work. The entries below
are what got it here. -
Objects one fiber owns are counted with plain instructions, not locked
ones. In a program that started a fiber, 0.3.0 counted every reference
with a locked instruction: 6,635 per request in a JSON service. An object
is local to the fiber that made it until the runtime hands it to another
fiber -- what a spawn captures, what a fiber answers or raises, what a
channel orHandoffsends, what aSharedcell holds -- and marks it
shared before handing it over. A local object's count is an ordinary load
and store, and a shared object's stays locked.KHORA_RC_LOCAL=0counts
every reference locked instead, for comparison against the locked path or
to rule this out while chasing an unrelated bug; the switch is part of the
build cache key. It rests on no fiber reaching a record with a mutable
field that another fiber holds. The checker refuses the three routes known
to do that, each listed under Breaking: a capture whose type is settled
after a spawn or a handler, and a fiber's error. If one is missed, a write
to that field races the counts of what it holds, which can crash a release
build. A debug build records the fiber that made each object and stops
withkhora: object made on fiber N was counted on fiber M without being shared -- a runtime entry published it without marking itif another
fiber counts it unmarked. Measured on an idle 16-thread laptop, the JSON
service used 6% less CPU per request on the thread backend and 1.5% less
on the scheduler. A release build takes about 20% longer, and the binary
is about 1.6% larger. Bit 63 of an object's count word is the shared mark:
a C caller reading the count word directly has to mask it, and
khora_refcountanswers the count alone. -
On the scheduler backend, a fiber woken by another fiber runs on the
waking fiber's worker. A channel send that releases a receiver, a pooled
connection handed back, a join: the woken fiber goes to the queue of the
worker the waking fiber is running on, and that worker runs it when the
waking fiber waits, with no signal to any other thread. If the waking fiber
keeps running, or blocks its thread, another worker takes it by stealing:
an idle worker as soon as it looks for work, and a busy one once that
worker has not started a fiber in about thirty-one of the busy one's
turns. A wake from a timer or a thread that is not a fiber wakes an idle
worker, and so does a fiber's wake when its worker's queue already holds
512 fibers.KHORA_WAKE_LOCAL=0sends every wake to the shared queue.
Measured on the TechEmpower server, four CPUs, 256 connections: server CPU
per request 226 -> 190 us on the single-query test, 4,162 -> 2,850 on
twenty queries and 322 -> 245 on Fortunes, with the work spread across the
four workers as evenly as with the switch off. Nothing a program computes
is different.KHORA_SCHEDULER_REPORTlines carry the counts of each path
and each worker's turns. -
On the scheduler backend, workers find ready sockets themselves. A
worker checks for ready sockets between fibers, every eighth fiber it runs
and whenever it runs out of work, and runs what it finds on its own queue
with no signal to another thread. The scheduler's watcher thread checks
only when no worker has for 2 ms, as when every worker is held by a
long-running fiber, and wakes an idle worker for what it finds. The timer
thread sleeps while no fiber is waiting on a deadline.KHORA_WAKE_LOCAL=0
sends what a worker finds to the shared queue, like every other wake.
Measured on the TechEmpower server, one CPU, 64 connections, per
request: kernel instructions 42.3k -> 28.7k on/jsonand 116k -> 76k on
/fortunes, context switches 0.99 -> 0.09 and 3.2 -> 0.4, user
instructions unchanged.KHORA_SCHEDULER_REPORTlines carry
worker_polls,backstop_pollsandtimer_passes. -
A channel send wakes one blocked receiver, not every one, on both
backends. On the default thread backend every fiber is a thread, and a send
woke every thread blocked on the channel, all but one of which found nothing
and blocked again; on the scheduler backend a send woke every fiber parked
on the channel, with the same result. A connection pool is a channel, so
with more requests than connections, each connection given back woke every
waiting request. A send wakes one blocked receiver and a receive wakes one
blocked sender, in the order they began waiting; a receiver or sender that
stops waiting for any other reason, a cancellation included, takes no later
value's wake with it.closestill wakes everyone, and a canceled fiber is
still woken at once. Measured on Linux x86-64, thread backend, the
TechEmpower single-query test (64 connections, a pool of 16): server CPU
per request 368 -> 229 us, 1.6× the requests per second. -
A server on the scheduler backend answers about a quarter more requests
at 256 connections. Every socket wait, wake and poll scanned one list of
every watched socket behind one lock, and workers queued on it while
requests waited. Measured with a smallstd::net::httpRouterservice on
Linux x86-64, scheduler backend, server on 4 CPUs and load generator on 2:
71.6k -> 88.9k requests per second at 256 connections, +2% at 32. The
thread backend does not use this code and is unchanged. -
Performance: the
postgresdriver prepares each statement once per
connection and copies each message once. A statement's later runs send
Bind, Execute and Sync with no Parse or Describe, a refusal forgets the
statement, and a connection keeps at most 512, closing the least recently
used. Building a query pushed every byte three times and copied it into an
array three times, and reading a reply copied the rest of the buffer once
per message, so a reply of n rows cost n copies of its tail. Each message
is built in a byte array that doubles and copies in bulk, and read where
it arrived. A row's cells are built as aListdirectly, integer columns
are read from their digits, and a reply's column names are built once
rather than once per row.postgres::wire::Messagehasbuffer,from
andwidthin place ofpayload. The bytes sent for a statement's first
run are unchanged. Measured on Linux x86-64 against PostgreSQL 17, 64
connections, the copying alone took the single-query test from 229 to
187 us of server CPU per request on the thread backend and from 244 to 196
on the scheduler, and Fortunes from 415 to 350 (thread backend); prepared
statements and reading messages in place took a further 12.7% off the
instructions per single-query request and 16.9% per twenty-query request. -
Performance: the allocation, copying and counting on a server's request
path. The same answers with less work, measured on the TechEmpower
server:- a new array's fill is written in bulk: nothing for a zero fill, one
memsetfor a byte, and doubling copies for anything wider, where it was
onememcpycall per element. HTTP connections keep one read buffer
each; Int::to_stringbuilds one buffer; aString's order is onememcmp;
List::sort_bysorts in an array and rebuilds the list, so it is stable
as before and recurses nowhere;String::joinmeasures the pieces, allocates one buffer and copies each
piece into it once: 68 allocations to join 64 pieces, against 380;- a
+chain on strings,"<${a}|${b}>"ora + b + c, allocates the
result once and copies each piece once, where each+allocated and
copied the text so far; every piece stays alive until the result is
built; with_datagiven a lambda written in place builds the closure in the
caller's frame, not on the heap: one allocation fewer per call;- a field read or write through a local or parameter counts nothing on the
record, unless the value written assigns something or reads that
binding; - a
matcharm that passes what it bound to a function hands over its own
reference, in a body that can raise and in a loop as well as elsewhere:
inmatch receive(h) { Option::Some(c) => consume(c), .. }the value
arrives atconsumeheld once, soconsumecan rebuild it in place,
where it arrived held twice and was copied; - releasing the last reference to an object frees its children by
recursion up to 24 levels deep, and queues whatever lies deeper, so a
list of any length is still freed in bounded stack; std::net::httpreads a request'sContent-Lengthin place: finding
where a request ends allocates nothing per header;- the scheduler's fiber tables hash an id with a multiply, folding the high
half into the low so ids a power of two apart spread across the table.
Every value these produce is unchanged, including the text of the most
negativeIntand the order of strings with bytes above0x7f. The array
fill and copies alone took server CPU per request on the thread backend
from 47 to 34 us on JSON, 219 to 141 on one query, 3,634 to 2,497 on
twenty queries and 409 to 204 on Fortunes, measured on a loaded machine;
each of the others took between 3% and 13% off the instructions per
/jsonrequest. - a new array's fill is written in bulk: nothing for a zero fill, one
-
An HTTP
Transport'sreceiveis handed the same buffer on every read
of a connection. The buffer is lent for the call: a custom transport
that keeps the array it was given (a recorder, a tee, a TLS shim that
decodes later) finds it holding the latest read, and has to copy the
bytes it keeps before returning. The transportsstd::net::httpprovides
copy at once and are unaffected. -
String literals and constructors with no fields are never reference
counted. Every fiber that touched""orOption::Nonewrote the same
count, so on several cores those writes contended. They are immortal and
live in read-only memory. Each count operation pays one more load and
compare, and skips the write for them. Measured in release builds on
Linux x86-64, on the thread backend, as paired throughput against the
same build without it:- a handler that parses and re-encodes a 350-byte JSON document:
1.07× on one core, 1.38× on four; - a handler that lower-cases, splits and joins 64 strings: 1.11× on one
core, 1.83× on four; - a
std::net::httpRouterservice answering a constant health check:
1.02× on one core, 1.06× on four.
Object code grows by about 15%, the linked binary by about 0.5%, and a
release build of that service takes about 13% longer. The scheduler
backend and non-x86 targets are unmeasured. - a handler that parses and re-encodes a 350-byte JSON document:
-
Cancellation costs something in functions with no
raisesrow, and less
in functions with one. A function that can reach a cancellation point
returns a small tag beside its answer, and its caller checks it. Measured
in release builds on Linux x86-64, on the default thread backend:- a tight loop is about 1.2× slower, recursion about 1.6×, and a very
short loop inside a function called in a hot path up to about 3.8×.
Ordinary iteration over collections is unchanged; - the cancellation check in a loop in a function that can fail went from
8.7× the cost of the same loop without a row to 1.15×; - a single
Fiber::spawnanywhere in a program made every call-free loop
in it about 1.8× slower. The same loop took 530 ms in such a program,
and takes 344 ms.
Object code grows by about a fifth. The scheduler backend and Windows are
unmeasured. - a tight loop is about 1.2× slower, recursion about 1.6×, and a very
-
Fiber::waitneeds noraisesrow.waitis accepted in any
function; it andjoinneed a!only when the child's own row is
non-empty. 0.3.0 refused awaitin a function with noraisesclause,
and refused a generic one only at build time. -
A segmentation fault is reported as one. The message that said "the
stack ran out" for every segmentation fault says it may also be the
runtime touching memory that is not the program's, and asks for a report.
Its note names the recursion that can still follow its input: the
program's own functions, a derivedEq,OrdorShowon a deeply
nested value, andstd::json::encodeon aJsonthe program nested very
deeply. 0.3.0's note namedstd::json::parse, which limits its nesting
(see Fixed). -
khora fmtends a list broken across lines with a comma: a parameter
or argument list, a list, a record literal or type, and type arguments and
parameters, so adding an element changes one line. Never a tuple or a
parenthesized expression, where(e,)is a one-tuple, and never after a
comment or a row tail. A filekhora fmt --checkaccepted before may need
formatting again;stdand thepostgresandotlppackages have been. -
Lockfiles write a
pathdependency outside the project relative to the
lockfile, as../../packages/postgres, never as an absolute path. A
committed lockfile named the checkout it was last resolved in, and a
build in any other checkout rewrote it.
Added
-
Labeled arguments. At a call to a named function an argument may be
writtenname: value, wherenameis the name the declaration gives the
parameter at that position:reply(connection, "ok", keep_alive: false).
The compiler checks the label and nothing else changes: arguments are
still matched by position and evaluated in the order written, so a label
that names a different parameter is refused, as are an unknown label, a
duplicate,_:andself:. A trait method is labeled by the trait's
parameter names however it is called, and an impl's own names stay local
to its body. A case with a named payload takes labels; a call through a
function value, a closure or an effect operation takes none, because a
function type has no parameter names. Labeled arguments are not named
arguments in the Python or Kotlin sense: there are no default values, and
a label cannot reorder or skip an argument. Defaults are a separate design
question. A public function's parameter names are part of its interface:
renaming one breaks any caller that labeled it, and
reference/compatibilitylists it as breaking. The editor offers the
label for the parameter at the cursor, and signature help shows the names
a call may use,extern fnincluded (see Editor for rename). -
if,forand spread inside list literals.
[header, if debug => trace, for r in rows => render(r), ..footer]:if c => xisxwhencholds and nothing otherwise, with an optionalelse;
for p in xs => eis oneeper item;..xsis every element ofxs. The
forms nest and are evaluated left to right.=>is what marks an element
form, so[if c { x }]is a one-element list holding anifexpression. A
forelement needsStepandIteratorin scope, is a cancellation
point, and refusesbreakandcontinue. -
std::core::Handoff<A>: a queue that gives values away. AChannel
carries onlySharevalues; a hand-off carries anything, including a
record withmutfields, anArrayor aMap, because the sender keeps
nothing.Handoff::bounded,send,receiveandclose. A send checks
by reference count that nothing else holds a writable part of the value,
and stops the program naming the type if something does;Shareparts may
be held elsewhere and are marked, as a channel marks what it sends. The
receiver counts what it received as its own, with plain counts. The check
walks every object in the value not already shared: about 2,400
instructions per send for a record of about a dozen objects. -
Fiber::abort(handle)stops a fiber at its next cancellation point
including inside its cleanup, and the children of any nursery it holds.
Cleanup otherwise runs to completion, and canceling again does not change
that. A finalizer written as a lambda directly inRegion::defer's
argument runs until its first cancellation point; one bound to alet
first, registered throughScope::deferoracquire, or whose first step
calls a function that itself calls a function value, can be skipped
whole.std::db::transaction's rollback and thepostgrespool's
give-back are written so thatabortcannot skip them. -
Fiber::cancel_within(handle, millis)cancels at once and aborts the
fiber if it is still running aftermillismilliseconds. There is no
built-in deadline: the caller always chooses the number. -
std::net::httpwritesDateon every response aConnection
sends, including theRouter's: the current second as an IMF-fixdate
(Sun, 06 Nov 1994 08:49:37 GMT), formatted once a second per
connection. A response that sets its ownDateheader, in any case, is
sent with that one alone.Connection::timed(transport, most, clock)
takes the time from aClockof your choosing, so a test can fix it.
Response::renderedhas no clock and writes noDate. -
String::escape_html:&,<,>,"and'replaced by&,
<,>,"and', for text going into an HTML element
or a quoted attribute. Plain runs are copied whole in one runtime scan, and
text with nothing to escape comes back without a copy. -
Array::copy_into(source, from, target, at, count)and
String::copy_into(from, target, at, count)copy bytes into an
Array<U8>in one move. The ranges are checked once, and one that does
not fit stops the program with the index-out-of-range trap; the source
and target may be the same array and may overlap. -
Lint groups. A group is a TOML file naming built-in lints and the
level each runs at when the group is on.[lints.<group>]inkhora.toml
switches a group on, and an optionallevelsets every lint in it. A lint's
own[lints]entry beats the group, whatever order the tables are in. One
group'slevelalso decides a lint that another enabled group holds, and
two enabled groups that disagree at the same step are an error naming
both. The toolchain shipsidiomatic(below). A project declares its own
groups by path under[lint-groups], and a workspace member with
lints.workspace = truetakes the root's[workspace.lint-groups]with
its lints. A group file that is missing, does not parse or names something
that is not a lint stopscheckandbuildwith the file and the key.
The editor applies groups at the same levels.// @klint allow <group>is
reported byunknown-allowas naming a group, with the group's lints
listed. -
Eight lints in the
idiomaticgroup, each flagging correct code
written in a second form where Khora has a first one. Each isallow
unless[lints.idiomatic]is in the manifest, andwarnin the group:concatenated-string("a " + xfor"a ${x}"),needless-return(a
lastreturn e;for the taile),subtraction-from-zero(0 - 1for
-1,Intliterals only),parenthesized-parameter(fn (x) =>for
fn x =>),bool-comparison(b == trueandb == falseforband
!b) andmodule-path(module main;in a package for
module <package>::main;);method-call:x.m(a), whereT::m(x, a)says it.Tis the type
that declaresm, or the trait for a trait's method, including one
reached through a bound on a type parameter.x.m(a)still compiles; a
project with the group atdenyrefuses it;unlabeled-flag: atrueorfalsepassed without a label to a
parameter declaredBoolthat is not the first.reply(c, "ok", false)
is reported, and the message nameskeep_alive: falseas the edit. A
parameter whose type is a type variable (fold(true, step)) and a
first parameter (assert_that(false, "..")) are not reported. It has
no automatic fix.
-
khora check --fixrewrites what those lints report, in place, and
checks again. It fixes only lints atwarnordenyunder the manifest,
never a line a// @klint allowcovers, and only the package's own files.
Each fix is made only where the rewritten program means the same thing:
a$that meets a{across a join is escaped,0.0 - xand0 - xare
left alone,module-pathis fixed only insrc/main.khand
src/bin/*.khand only when no other file imports them, and no fix is
made where its first token would continue the statement before it. The
fixed package is checked before anything is written, and a fix that would
leave it failing to parse or type-check is not made and is printed as
not fixed. A fix is not made where it would remove the only thing that
gives a local with no written type, or a call's result, its type. Nothing
is fixed while a file in the package does not parse. Each pass is written
whole or not at all, through temporary files renamed into place.
method-callis rewritten with the receiver as the first argument, labels
kept, a piped value kept in its slot, and animportofTadded where
the file lacks one. That fix is withheld, and the finding kept, where
T::mwould reach something else (a type parameter or another type of
that name, a constructor namedm), where the import would clash with a
name the file has, and where the receiver's type is not settled.
Documentation
-
The concurrency, sharing, effects, fiber-internals, traps, debugging,
compatibility, limitations, migration-from-Go, bounded-concurrency,
timeouts-and-cancellation and HTTP-service pages describe cancellation as
it is: every function can be canceled, theraisesrow on a nursery's
children is for failures only, channel operations take no!, a helper
that only waits needs no row,abortandcancel_withinare how a stuck
cleanup is ended, and a child's failure cancels the siblings still running
when the nursery sees it, which depends on the order they were adopted in. -
The reference, the guide, the cookbook and
std's documentation write a
call asType::f(x).x.f()is accepted everywhere it was and is
documented as the second form, which theidiomaticgroup'smethod-call
reports. -
The lints and manifest reference pages describe lint groups. The grammar,
declarations and modules-and-packages pages describe imports without
globs and the one-with, one-raisesrule. Thepostgrespackage page
and the database-transactions cookbook describe how a connection is given
back to the pool and howtransactionrolls back after a cancel.
Editor
-
Imports from dependencies check in the editor.
khora lspread only the
opened folder and the standard library, so every file importing a package
from[dependencies]showedcannot find moduleand errors on each
imported name, whilekhora checkaccepted it. The server reads installed
git and path dependencies from the lockfile and package store, without
fetching or writing anything. A dependency that is not installed yet gets a
message naming the command to run, and files that don't need it are still
checked. Dependency code outside the opened folder gets no diagnostics, and
rename and formatting never edit it. -
The language server starts. The VS Code extension 0.3.0 launched
khora lsp --stdio, which every released toolchain refuses, so the server
exited before it answered and nothing but syntax coloring worked. From
extension 0.3.2 it launcheskhora lsp, and works with every released
toolchain. -
khora lsp --stdiois accepted. Editor clients that name the stdio
transport,vscode-languageclientamong them, add--stdio.khora lsp
refused the unknown flag and exited before answering, so those editors
showed nothing. The flag is accepted and ignored: stdin and stdout are the
only transport. -
The extension's ID is
khora.khora. Version 0.3.0 was published as
khora-lang.khora, which VS Code treats as a different extension, so
remove it first or both will run:
code --uninstall-extension khora-lang.khora. "Install Khora", offered
when nokhorais found, opens the installation page on khoralang.com;
0.3.0 opened a domain that does not resolve. -
Hovering a binding's name shows the binding's type. On the name a
let, a pattern, aforloop, a lambda parameter or amatcharm binds,
hover showed the type of the block, lambda or loop around it, highlighted
over that whole construct:let s = "hi"inside a function returning
IntsaidInt. It saysString, overs. -
A rename that would change the program is refused. Renaming a local or
a parameter to something that is not a usable name (_,self, a
reserved word, anything that is not an identifier), or onto a name already
bound in the same function, is refused and changes no file. On 0.3.0 the
rename was made, and renaming one local onto another's name could build a
program that computed a different answer. Renaming a parameter renames the
labels written against it in every file. -
"Apply idiomatic fixes" in the editor, a
source.fixAll.khoracode
action, makes the same fixes askhora check --fix. Editors run it on save
when configured to runsource.fixAll; it is not offered in the lightbulb
menu. -
The extension's page in VS Code and on the Marketplace says how to get
started, and lists every feature, both settings with their defaults, the
commands, and what to do when the server does not start. -
The "add this error to
raises" quick fix writes the clause after a
withrow. On a function with awithclause it wrote
raises E with {..}, a shape that is a syntax error in this release and
that 0.3.0 accepted without reading all of it. -
Publishing the extension checks that the packaged
.vsixstarts a
language server before anything goes out. The check spawnskhora lsp
with the arguments the extension passes and sends it aninitialize
request, so a release that would again ship a server that cannot start
fails before publishing rather than after. -
The packaged extension carries a license file, and is one bundled
file instead of several hundred unbundled ones. Neither changed what the
extension does; both were warnings from the packaging tool with nothing
to show for them in the installed extension.