Coming from Node.js
You know the Node.js event loop: one thread, callbacks and promises, async/await, EventEmitter, AbortController, child_process, worker_threads. This page maps each of those onto sparkles:event-horizon, explains where the two models deliberately differ, and gives you a runnable D program for every mapping.
Last reviewed: September 7, 2026
NOTE
Every implementation below is a complete executable tutorial, with error handling and cleanup. Assertion-heavy contract examples are linked after each comparison. D files run with dub run --single <file> -b checked; Node.js files run with node <file>. The labelled output tabs belong to the immediately preceding source. ci --verify checks both tutorial outputs and independently runs the contract counterparts with assertions enabled. Linux prerequisites and dependency versions are documented in Running the examples.
The one-paragraph difference
On Linux, libuv commonly reacts to socket readiness, performs I/O, and dispatches JavaScript callbacks. Event Horizon's io_uring backend submits the operation itself and receives a completion. This is a backend distinction, not a claim that Node's Windows IOCP backend is readiness-based or that kqueue is a completion ring. On top of its completion interface Event Horizon runs fibers: your code looks blocking (recv, sleep, accept return values), but each call parks the fiber on a submission and resumes it on the completion. There is no await keyword, no function coloring, or JavaScript-style microtask ordering. Its kqueue backend synthesizes completions over readiness; see the platform notes.
Concept map
| Node.js | event-horizon | Notes |
|---|---|---|
| the process-wide event loop | LoopGroup + RootScope + Env | one loop per thread; you start it, it does not start itself |
Promise<T> / rejection | IoResult!T (a value or an IoError) | ordinary I/O errors are values; defects remain distinct |
async function / await | a fiber; every verb is a checkpoint | recv, sleep, accept park the fiber and return the result |
setTimeout / setInterval | env.clock.sleep / Ticker | Ticker avoids cumulative delay drift; individual wakeups can still be late |
Promise.all / Promise.allSettled | withScope + fork/join | the scope cannot exit until every child is done |
Promise.race | race | losers are cancelled and joined; this is not Promise.any semantics |
AbortController / AbortSignal.timeout | Scope.cancel / withDeadline / protect | cancellation is a tree, delivered at checkpoints, and cleanup runs shielded |
| streams' backpressure | Channel!(T, capacity) | bounded queue, not a broadcast emitter; scheduler-local |
net.createServer / net.connect | env.net.listen / env.net.connect, accept/recv/send | buffers move in and come back (the kernel owns them mid-flight) |
fs.promises.readFile | openFile + read | Linux io_uring submits reads; backend implementations differ |
child_process.exec | capture | concurrent drains and root reap ownership |
child_process.spawn + 'data' events | supervise + ProcessEvents | framed lines, timeouts, tree kill, resource accounting |
process.on('SIGINT') | SignalFd | signals are completions, not handlers |
fs.watch | Watcher | inotify events through the loop |
p-retry / hand-rolled backoff | retry + schedules (exponential & recurs) | schedules are pure values; time comes in, decisions come out |
worker_threads | LoopGroup topologies | default is one loop; multi-worker ownership must be designed explicitly |
| unhandled rejection event | explicit result checking | dropping an IoResult does not automatically report an error |
The EH programs use runApplication: it starts a single-scheduler runtime, provides the root scope and capabilities, then joins and cleans up. The body returns IoResult; the application receives an Outcome that distinguishes ordinary failure from cancellation and defects. For reusable or multi-worker runtimes, use LoopGroup explicitly instead.
Timers: setTimeout is a parked fiber
Both examples print three ticks separated by relative delays. A delay starts when requested: it is not an absolute ticker and does not prove drift-free cadence. env.clock.sleep parks this fiber; Node's promise timer suspends this async function. Neither promises exact wall-clock execution under load.
#!/usr/bin/env node
import { setTimeout as sleep } from 'node:timers/promises';
for (let tick = 1; tick <= 3; ++tick) {
await sleep(10);
console.log('tick', tick);
}tick 1
tick 2
tick 3#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_eh_timers"
dependency "sparkles:event-horizon" path="../../../../../.."
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import core.time : msecs;
import std.stdio : writeln, stderr;
import sparkles.event_horizon : runApplication, RootScope, Env, ioOk;
int main() @system
{
auto result = runApplication((ref RootScope root, ref Env env) {
foreach (tick; 1 .. 4)
{
auto slept = env.clock.sleep(10.msecs);
if (slept.hasError) return slept;
writeln("tick ", tick);
}
return ioOk();
});
if (result.hasError) stderr.writeln(result.error);
return result.hasError ? 1 : 0;
}tick 1
tick 2
tick 3Contract examples: Node.js · event-horizon.
Concurrency: joining promises versus owning children
Both examples start two delayed computations before joining their results. Promise.all joins these successful promises, but does not itself own or cancel underlying work on failure. A scope owns its children: withScope does not return until every fiber it spawned has finished, and a child's typed result comes back through a JoinHandle. That is the whole of structured concurrency — there is no way to leak a running fiber past the block that created it.
The EH example joins both handles before reporting their outcomes. A fork's typed failure goes to its join handle; it does not itself invoke the scope's sibling-cancellation policy. Here root.fail explicitly promotes a joined failure into the root outcome. This is not JavaScript's fail-fast promise behavior.
#!/usr/bin/env node
import { setTimeout as sleep } from 'node:timers/promises';
async function work(value, delay) {
await sleep(delay);
return value;
}
const [a, b] = await Promise.all([work(1, 20), work(2, 5)]);
console.log('joined:', a * 10 + b);joined: 12#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_eh_concurrency"
dependency "sparkles:event-horizon" path="../../../../../.."
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import core.time : msecs;
import std.stdio : writeln, stderr;
import sparkles.event_horizon;
int main() @system
{
auto result = runApplication((ref RootScope root, ref Env env) {
JoinHandle!int slow, fast; // Slots remain pinned until both joins.
root.fork(slow, () {
auto slept = env.clock.sleep(20.msecs);
return slept.hasError ? ioErr!int(slept.error) : ioOk(1);
});
root.fork(fast, () {
auto slept = env.clock.sleep(5.msecs);
return slept.hasError ? ioErr!int(slept.error) : ioOk(2);
});
auto a = root.join(slow);
auto b = root.join(fast);
if (a.hasError) root.fail(a.error);
if (b.hasError) root.fail(b.error);
if (!a.hasError && !b.hasError) writeln("joined: ", a.value * 10 + b.value);
return ioOk();
});
if (result.hasError) stderr.writeln(result.error);
return result.hasError ? 1 : 0;
}joined: 12Contract examples: Node.js · event-horizon.
Cancellation and timeouts: AbortController is a cancel scope
In Node, honouring a signal is the callee's job — every layer must thread signal through and check it. Here a deadline is a cancel scope: withDeadline interrupts every checkpoint inside it, the operation that was parked returns ECANCELED, and the scope's outcome says it was a timeout. Cleanup that must not be interrupted runs under protect.
#!/usr/bin/env node
import { setTimeout as sleep } from 'node:timers/promises';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 50);
let cleaned = false;
try {
await sleep(10_000, undefined, { signal: controller.signal });
} catch (error) {
if (error.name !== 'AbortError') throw error;
console.log('sleep returned:', error.name);
} finally {
clearTimeout(timeout);
await sleep(5); // Cleanup deliberately does not receive the aborted signal.
cleaned = true;
}
console.log('timed out:', controller.signal.aborted);
console.log('cleaned up:', cleaned);sleep returned: AbortError
timed out: true
cleaned up: true#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_eh_deadline"
dependency "sparkles:event-horizon" path="../../../../../.."
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import core.time : msecs, seconds;
import core.stdc.errno : ECANCELED;
import std.stdio : writeln, stderr;
import sparkles.event_horizon;
int main() @system
{
auto result = runApplication((ref RootScope root, ref Env env) {
ref scheduler = currentScheduler();
bool cleaned;
auto timed = withDeadline!((ref scope_) {
auto slept = env.clock.sleep(10.seconds);
if (slept.hasError && slept.error.code != ECANCELED)
scope_.fail(Cause!IoError.fromFailure(slept.error));
writeln("sleep returned: ", slept.hasError ? "ECANCELED" : "ok");
// Cleanup is allowed to finish even with cancellation latched.
auto cleanup = protect!(() => env.clock.sleep(5.msecs))(scheduler);
cleaned = !cleanup.hasError;
if (cleanup.hasError) scope_.fail(Cause!IoError.fromFailure(cleanup.error));
})(scheduler, 50.msecs);
if (timed.hasError && !timed.error.isTimeout) root.fail(timed.error);
writeln("timed out: ", timed.hasError && timed.error.isTimeout);
writeln("cleaned up: ", cleaned);
return ioOk();
});
if (result.hasError) stderr.writeln(result.error);
return result.hasError ? 1 : 0;
}sleep returned: ECANCELED
timed out: true
cleaned up: trueContract examples: Node.js · event-horizon.
Events and backpressure: choose a bounded channel
A Channel!(T, capacity) is a queue, not an EventEmitter broadcast substitute. put parks the producer while the buffer is full, so backpressure is what you get by default. close wakes every waiter: takers drain what is buffered and then see EPIPE.
#!/usr/bin/env node
import { Writable } from 'node:stream';
import { once } from 'node:events';
import { finished } from 'node:stream/promises';
let sum = 0;
const sink = new Writable({
objectMode: true,
highWaterMark: 2,
write(value, encoding, done) {
sum += value;
setTimeout(done, 2); // A slow consumer.
},
});
const completion = finished(sink);
try {
for (let value = 1; value <= 5; ++value) {
if (!sink.write(value)) await once(sink, 'drain');
}
sink.end();
await completion;
console.log('consumed:', sum);
} finally {
sink.destroy();
}consumed: 15#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_eh_channel"
dependency "sparkles:event-horizon" path="../../../../../.."
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import core.stdc.errno : EPIPE;
import std.stdio : writeln, stderr;
import sparkles.event_horizon;
int main() @system
{
Channel!(int, 2) items; // Outlives every producer/consumer operation.
auto result = runApplication((ref RootScope root, ref Env env) {
ref scheduler = currentScheduler();
if (!root.spawn({
scope(exit) items.close();
foreach (value; 1 .. 6)
{
auto put = items.put(scheduler, value); // Parks when full.
if (put.hasError) { root.fail(Cause!IoError.fromFailure(put.error)); return; }
}
})) return ioOk();
int sum;
for (;;)
{
auto next = items.take(scheduler); // Parks when empty.
if (next.hasError)
{
if (next.error.code != EPIPE) return ioErr!void(next.error);
break; // Closed and drained.
}
sum += next.value;
}
writeln("consumed: ", sum);
return ioOk();
});
if (result.hasError) stderr.writeln(result.error);
return result.hasError ? 1 : 0;
}consumed: 15Contract examples: Node.js · event-horizon.
Sockets: net.createServer is listen + accept in a fiber
The shape is the same — a listener, a connection per client — but each side is a fiber running sequential code. One thing is genuinely different: the buffer moves. The kernel owns it while the operation is in flight, so recv(move(buf)) hands it over and the result hands it back. This expresses ownership, not a zero-copy guarantee. sendAll and readExactly handle partial transfers while returning the buffer owner. The EH example uses a five-byte frame; Node ends the request stream and reads the echo through EOF. TCP itself preserves neither write boundaries nor application messages.
#!/usr/bin/env node
import net from 'node:net';
import { once } from 'node:events';
const peers = new Set();
let serverError;
const server = net.createServer(socket => {
peers.add(socket);
socket.on('error', error => {
serverError = error;
socket.destroy();
});
socket.once('close', () => peers.delete(socket));
socket.pipe(socket); // Handles backpressure; EOF ends the response.
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const client = net.connect(server.address().port, '127.0.0.1');
client.setTimeout(5000, () => client.destroy(new Error('echo timed out')));
try {
await once(client, 'connect');
client.end('hello');
const chunks = [];
for await (const chunk of client) chunks.push(chunk);
if (serverError) throw serverError;
console.log('echoed:', Buffer.concat(chunks).toString());
} finally {
client.destroy();
for (const peer of peers) peer.destroy();
await new Promise(resolve => server.close(resolve));
}echoed: hello#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_eh_tcp_echo"
dependency "sparkles:event-horizon" path="../../../../../.."
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import core.lifetime : move;
import std.stdio : writeln, stderr;
import sparkles.base.buffer : UniqueBuffer;
import sparkles.event_horizon;
// A five-byte frame: the helpers handle short transfers, not message framing.
IoResult!void echo(ref Listener listener) @system
{
auto accepted = listener.accept();
if (accepted.hasError) return ioErr!void(accepted.error);
auto peer = accepted.value;
scope(exit) peer.close();
UniqueBuffer!(ubyte, 5) frame;
frame.length = 5;
auto received = peer.readExactly(move(frame));
if (received.res.hasError) return received.res;
return peer.sendAll(move(received.buf)).res;
}
int main() @system
{
Listener listener; // Outlives the joined root and every server operation.
scope(exit) listener.close();
auto result = runApplication((ref RootScope root, ref Env env) {
auto opened = env.net.listen(ipv4("127.0.0.1", 0));
if (opened.hasError) return ioErr!void(opened.error);
listener = opened.value;
auto address = listener.localAddress();
if (address.hasError) return ioErr!void(address.error);
if (!root.spawn({
auto served = echo(listener);
if (served.hasError) root.fail(Cause!IoError.fromFailure(served.error));
})) return ioOk(); // Rejection already records the scope failure.
auto connected = env.net.connect(address.value);
if (connected.hasError) return ioErr!void(connected.error);
auto client = connected.value;
scope(exit) client.close();
UniqueBuffer!(ubyte, 5) frame;
frame ~= cast(const(ubyte)[]) "hello";
auto sent = client.sendAll(move(frame));
if (sent.res.hasError) return sent.res;
auto reply = client.readExactly(move(sent.buf));
if (reply.res.hasError) return reply.res;
writeln("echoed: ", cast(const(char)[]) reply.buf[]);
return ioOk();
});
if (result.hasError) stderr.writeln(result.error);
return result.hasError ? 1 : 0;
}echoed: helloContract examples: Node.js · event-horizon.
Files: bounded whole-file reads
Node's async file I/O is a thread pool because epoll cannot express a file read. io_uring can. A program opens a directory as a capability with openRoot(&env.fs(), …), names the file in it, and readText reads it through the ring; the file closes itself. The explicit byte limit prevents unbounded accumulation, and the returned string is GC-allocated without Unicode validation.
#!/usr/bin/env node
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const directory = await mkdtemp(join(tmpdir(), 'node-file-'));
try {
const path = join(directory, 'input');
await writeFile(path, 'hello from a file\n');
const data = await readFile(path);
console.log(`read ${data.length} bytes: ${data}`);
} finally {
await rm(directory, { recursive: true });
}read 18 bytes: hello from a file#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_eh_file_read"
dependency "sparkles:event-horizon" path="../../../../../.."
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import std.file : remove, tempDir, write;
import std.path : buildPath;
import std.process : thisProcessID;
import std.conv : to;
import std.stdio : writeln, stderr;
import expected : andThen;
import sparkles.base.vfs : OpenMode, Rights, ambientAuthority, openRoot;
import sparkles.event_horizon : runApplication, RootScope, Env, ioErr, ioOk, readText;
int main() @system
{
const name = "eh-tutorial-" ~ thisProcessID.to!string ~ ".txt";
write(buildPath(tempDir, name), "hello from a file\n");
scope (exit) remove(buildPath(tempDir, name));
auto result = runApplication((ref RootScope root, ref Env env) {
// `env.fs` opens a directory as a capability; the file is named in it.
auto dir = openRoot!(Rights.readOnly)(&env.fs(), tempDir, ambientAuthority());
if (dir.hasError)
return ioErr!void(dir);
auto file = dir.value.openFile!(OpenMode.read)(name);
if (file.hasError)
return ioErr!void(file);
return readText(file.value, 1024).andThen!((text) {
writeln("read ", text.length, " bytes: ", text);
return ioOk();
});
});
if (result.hasError) stderr.writeln(result.error);
return result.hasError ? 1 : 0;
}read 18 bytes: hello from a fileContract examples: Node.js · event-horizon.
Child processes: exec is capture, spawn is supervise
capture spawns, drains both pipes concurrently (so a chatty child can never deadlock against an undrained pipe), and reaps exactly once. A non-zero exit is data, not an error.
#!/usr/bin/env node
import { execFile } from 'node:child_process';
// A nonzero child exit is part of this demonstration, not a launch failure.
const { error, stdout, stderr } = await new Promise(resolve => {
execFile(
'sh',
['-c', 'echo out; echo err >&2; exit 3'],
{ timeout: 5000 },
(error, stdout, stderr) => resolve({ error, stdout, stderr }),
);
});
if (error && (typeof error.code !== 'number' || error.killed)) throw error;
console.log('stdout:', stdout);
console.log('stderr:', stderr);
console.log('exit code:', error?.code ?? 0);stdout: out
stderr: err
exit code: 3#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_eh_exec"
dependency "sparkles:event-horizon" path="../../../../../.."
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import std.stdio : writeln, stderr;
import sparkles.event_horizon;
int main() @system
{
auto result = runApplication((ref RootScope root, ref Env env) {
ProcessConfig config;
config.stderrSpec = StdioSpec(StdioMode.pipe);
auto captured = capture(currentScheduler(),
["sh", "-c", "echo out; echo err >&2; exit 3"], config);
if (captured.hasError) return ioErr!void(captured.error);
writeln("stdout: ", cast(const(char)[]) captured.value.stdout_[]);
writeln("stderr: ", cast(const(char)[]) captured.value.stderr_[]);
writeln("exit code: ", captured.value.status.code); // Nonzero exit is data.
return ioOk();
});
if (result.hasError) stderr.writeln(result.error);
return result.hasError ? 1 : 0;
}stdout: out
stderr: err
exit code: 3Contract examples: Node.js · event-horizon.
supervise owns the whole run: it frames each stream into lines, feeds stdin, applies a timeout as TERM-then-grace-then-KILL to the child's process tree (a fresh process group, plus a cgroup where the host delegates one), samples the tree's resource usage, and delivers every event on your fiber in order. The exited event is published exactly once after the terminal sequence. Streams can be forcibly closed at the drain limit, and the result records reap provenance and termination degradation. Process groups and post-spawn cgroup migration are not inescapable containment; see the supervision caveats.
The diagram shows the timeout path. The executable programs below instead wait for the child's second line before requesting termination. EH deliberately cancels its root, waits for supervision to drain and reap, then handles that expected cancellation at the application boundary. Node sends SIGTERM to its single child and waits for close; it does not acquire EH's tree policy.
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
import { once } from 'node:events';
const child = spawn(
process.execPath,
['-e', "console.log('one'); console.log('two'); setInterval(() => {}, 1000)"],
{ stdio: ['ignore', 'pipe', 'inherit'] },
);
const reader = createInterface({ input: child.stdout });
const watchdog = setTimeout(() => child.kill('SIGKILL'), 5000);
try {
const [, [, signal]] = await Promise.all([
(async () => {
for await (const line of reader) {
console.log('line:', line);
if (line === 'two') child.kill('SIGTERM'); // Readiness, not a guessed delay.
}
})(),
once(child, 'close'), // Includes output drainage as well as root exit.
]);
console.log('exited:', signal);
console.log('end: closed');
} finally {
clearTimeout(watchdog);
reader.close();
if (child.exitCode === null && child.signalCode === null) {
const closed = once(child, 'close');
child.kill('SIGKILL');
await closed;
}
}line: one
line: two
exited: SIGTERM
end: closed#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_eh_spawn"
dependency "sparkles:event-horizon" path="../../../../../.."
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import core.time : msecs;
import std.stdio : writeln, stderr;
import sparkles.event_horizon;
int main() @system
{
bool requestedStop, supervisionFailed;
auto result = runApplication((ref RootScope root, ref Env env) {
SupervisedProcessConfig config;
config.terminateGrace = 500.msecs;
config.process.stdinSpec = StdioSpec(StdioMode.nullDev);
auto child = supervise(currentScheduler(),
["sh", "-c", "echo one; echo two; exec sleep 30"], config,
null, (in ProcessEvent event) {
if (event.kind == ProcessEventKind.line)
{
auto line = cast(const(char)[]) event.line.bytes;
writeln("line: ", line);
if (line == "two") { requestedStop = true; root.cancel(); }
}
else if (event.kind == ProcessEventKind.exited)
writeln("exited: ", event.end, ", signaled by ",
event.status.signaled ? event.status.code : 0);
});
if (child.hasError) { supervisionFailed = true; return ioErr!void(child.error); }
writeln("end: ", child.value.end, ", reap: ", child.value.reap);
return ioOk();
});
// This example deliberately cancels its root after the second line.
if (!result.hasError || (requestedStop && !supervisionFailed
&& result.error.kind == Cause!IoError.Kind.interrupt
&& result.error.interrupt.kind == InterruptKind.cancelled)) return 0;
stderr.writeln(result.error);
return 1;
}line: one
line: two
exited: cancelled, signaled by 15
end: cancelled, reap: reapedContract examples: Node.js · event-horizon.
Signals: process.on('SIGINT') is a completion too
A SignalFd turns signals into completions the loop delivers to the fiber waiting on them. Create it before other threads that must inherit the blocked signal mask; this avoids running application logic in an asynchronous signal handler. It does not by itself make all application state race-free.
#!/usr/bin/env node
import { once } from 'node:events';
const received = once(process, 'SIGUSR1'); // Install before sending.
const watchdog = setTimeout(() => {
throw new Error('SIGUSR1 was not delivered');
}, 5000);
try {
process.kill(process.pid, 'SIGUSR1');
await received;
console.log('got signal SIGUSR1');
} finally {
clearTimeout(watchdog);
}got signal SIGUSR1#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_eh_signal"
dependency "sparkles:event-horizon" path="../../../../../.."
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import core.stdc.errno : errno;
import core.sys.posix.signal : SIGUSR1, kill;
import core.sys.posix.unistd : getpid;
import std.stdio : writeln, stderr;
import sparkles.event_horizon;
int main() @system
{
SignalFd signals; // Block before starting runtime worker threads.
auto opened = SignalFd.create(signals, [SIGUSR1]);
if (opened.hasError) { stderr.writeln(opened.error); return 1; }
auto result = runApplication((ref RootScope root, ref Env env) {
if (kill(getpid(), SIGUSR1) != 0) return ioErr!void(errno, OpKind.none);
auto received = signals.nextSignal(currentScheduler());
if (received.hasError) return ioErr!void(received.error);
writeln("got signal ", received.value == SIGUSR1 ? "SIGUSR1" : "other");
return ioOk();
});
if (result.hasError) stderr.writeln(result.error);
return result.hasError ? 1 : 0;
}got signal SIGUSR1Contract examples: Node.js · event-horizon.
Retries: a schedule is a value, not a loop you write
Schedules compose as values (exponential(5.msecs) & recurs(4) reads "back off exponentially, at most four times") and the retry driver sleeps through whatever clock you pass it — so a test can virtualise time with a TestClock.
#!/usr/bin/env node
import { setTimeout as sleep } from 'node:timers/promises';
let attempts = 0;
function request() {
if (++attempts < 3)
throw Object.assign(new Error('try again'), { code: 'EAGAIN' });
return attempts;
}
for (let retry = 0; ; ++retry) {
try {
console.log('attempts:', request());
break;
} catch (error) {
if (error.code !== 'EAGAIN' || retry === 4) throw error;
await sleep(5 * 2 ** retry);
}
}attempts: 3#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_eh_retry"
dependency "sparkles:event-horizon" path="../../../../../.."
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import core.time : msecs;
import core.stdc.errno : EAGAIN;
import std.stdio : writeln, stderr;
import sparkles.event_horizon;
int main() @system
{
auto result = runApplication((ref RootScope root, ref Env env) {
int attempts;
enum policy = exponential(5.msecs) & recurs(4);
auto tried = retry(root, env.clock, policy, () {
if (++attempts < 3) return ioErr!int(EAGAIN, OpKind.none);
return ioOk(attempts);
});
if (tried.hasError) root.fail(tried.error);
else writeln("succeeded on attempt ", tried.value);
return ioOk();
});
if (result.hasError) stderr.writeln(result.error);
return result.hasError ? 1 : 0;
}succeeded on attempt 3Contract examples: Node.js · event-horizon.
Where the models deliberately differ
- Explicit error checking. A failing operation returns an
IoResultyou must inspect; dropping it does not create an unhandled-rejection event.Throwables escaping a fiber) are defects and fail the scope that owns the fiber. - No fire-and-forget. Every fiber belongs to a scope, and a scope does not exit until its children have. Daemons (
spawnDaemon) are the escape hatch: they are reaped when the scope ends, not orphaned. - Cancellation is cooperative and structural. It latches on a fiber and is delivered at its next checkpoint — every I/O verb is one — and it descends a tree of scopes.
protectshields cleanup. Nothing is "aborted" between two statements. - Buffers move. A completion backend owns the buffer while an operation is in flight;
move(buf)in,move(result.buf)out. The contract keeps storage alive through completion; it does not eliminate every application-level alias or race. - No microtasks, no
nextTick. A fiber runs until it parks;yieldNowis the explicit "let others run" point when you need one in a CPU-bound loop. - Explicit topology. A
LoopGroupdefaults to one calling-thread loop. Multi-worker modes have thread-affine handles and scheduler-local state; changing topology is not a substitute for designing cross-thread communication.
Errors as values, in one table
| Node.js | event-horizon |
|---|---|
throw / rejected promise | IoResult!T = Expected!(T, IoError): hasValue, value, error |
err.code === 'ECONNRESET' | error.kind == ErrorKind.connectionReset, plus the raw code, op and stage |
AbortError / TimeoutError | Outcome with a Cause: isTimeout, Interrupt kind |
| uncaught exception | a defect: the scope fails with Cause.die, the Throwable travels to the joiner |
process.exit(1) on a hard fault | the loop's FatalHook — a backend that can no longer make progress ends the process, never returns |
Next steps
- The normative surface: SPEC.md — §7 fibers, §8 scopes and cancellation, §13 processes, §14 channels, §16 the public API.
- The research behind the design: Async I/O & Event Loops and Process Supervision.
- Standalone examples in
libs/event-horizon/examples/:callback-echo.d(tier A),fiber-echo.d(tier B),agent-tooling.d(processes, files and a watcher together).