Coming from vibe-core
The direct-style part should feel familiar: vibe-core already uses fibers so network and file waits can read like sequential code. The migration is primarily about task ownership, explicit results, and buffer lifetime.
This guide uses vibe-core at f0e8795495affd0d517b335e70c6f7139a6a9f5b, whose facilities include fiber-based tasks, I/O, timers, and synchronization. Its task implementation includes joining; describing all vibe-core tasks as necessarily detached would be misleading.
NOTE
Each source tab imports a complete executable tutorial with error handling and cleanup. Run it with dub run --single <file> -b checked. Contract examples linked after each group retain the behavioral assertions. ci --verify checks every displayed output and independently executes those contract counterparts. See Running the examples for prerequisites.
Choose the continuation style, not a different I/O engine
The six three-way groups below show vibe-core, event-horizon fibers, and event-horizon callbacks, each with its own verified output. Both EH styles submit completion operations. Callback programs create DefaultLoop directly; typed context handlers remove the raw cast, but state must still outlive every accepted operation. No callback example hides a fiber runtime.
| Responsibility | Tier A: callbacks | Fiber style |
|---|---|---|
| Continue after I/O | void function(void*, ref Completion) nothrow @nogc | Resume after a verb returns an IoResult |
| Keep state alive | Explicit context until terminal completion | Locals in a suspended fiber, owned by its scope |
| Start an operation | Check submit/submitAfter for admission failure | Check the operation's returned result |
| Observe completion | Completion.res is a value or negative errno | Typed IoResult!T; scope outcomes are separate |
| Own a buffer | Move into the op; recover/move Completion.buf | Move through a sequential I/O verb |
| Cancel | Request cancellation, then keep driving to terminal completion | Cancel the owning scope and join protected cleanup |
| Combine operations | Explicit pending count/state machine | fork/join within withScope |
A submission failure produces no callback. An accepted cancellation request is not permission to free the context: the target's terminal callback still runs, and a real completion may win the race. Do not call run/runOnce from a callback. The examples keep contexts and pools alive until inFlight == 0 and print after dispatch, outside the nothrow @nogc callback boundary.
Where a callback comparison stops being equivalent
The channel, signal, capture and supervision sections retain their fiber programs. Tier A has raw read/wait/process-related operations, but no drop-in callback version of scheduler-local Channel, scoped SignalFd consumption, execCapture, or supervise. Rebuilding their buffering, cancellation, drain, and reaping policies would be an application adapter, not a second spelling of the same high-level API. No such equivalence is implied by the six native operation comparisons.
Map the lifetime boundary
For a vibe-core port, Event Horizon's fiber style preserves the familiar sequential shape. Choosing Tier A instead is an explicit move to callback state machines: locals that previously survived a cooperative wait must move into a context that outlives every operation. Neither choice proves CPU parallelism.
| vibe-core pattern | Event Horizon fibers | Event Horizon callbacks |
|---|---|---|
| Fiber-aware sleep inside a loop | env.clock.sleep inside a loop | submitAfter rearms from its completion |
| Retained task handles and joining | Scoped fork/join with typed outcomes | Explicit fan-in state; no implicit child joining |
| Sequential TCP reads/writes | Owning stream verbs | OpRecv/OpSend and a framing state machine |
| Sequential file reading | Owned file/buffer verbs | Open → repeated positioned read → EOF → close |
| Timeout around cooperative work | withDeadline plus protected cleanup | Deadline callback → cancel request → terminal result → cleanup |
| vibe-core idiom | Event Horizon idiom | What to review |
|---|---|---|
| Launch a task and retain its handle | sc.fork(handle, body) | Choose the lexical scope that owns the child. |
| Join a task | handle.join(s) | Inspect the typed outcome before accessing its value. |
| Fiber-aware sleep | env.clock.sleep | Handle cancellation as an I/O result. |
| Task interruption or timeout | withDeadline, Scope.cancel | A deadline joins children; it cannot preempt CPU-bound code. |
| Synchronization and message passing | Scoped children and bounded channels | Event Horizon channels stay on their owning scheduler. |
| Streams and exception-based failures | Owned buffer verbs and IoResult!T | Ordinary I/O errors are values; defects still exist. |
LoopGroup defaults to one loop on the calling thread. A multi-worker topology is a separate choice, not an automatic effect of using a group.
The lifecycle is common; the mechanism is not. Callback registrations need an explicit owner and terminal path. Fibers make sequential control flow possible, but still need cancellation and lifetime rules. A lexical scope is useful when it actually owns the work being started.
Timers and cadence
Each program prints three ticks and then returns. EH uses relative sleeps or one-shot timers, not Ticker: work between waits shifts the next deadline. Neither style promises exact timing. For absolute cadence, maintain an absolute deadline and define how missed ticks are skipped.
The callback tutorial arms a timer, drives it to completion, prints its result, and repeats. The fiber tutorial loops over sleeps. The contract version also exercises rearming directly from a completion callback.
#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_vibe_timers"
dependency "vibe-core" version="2.14.0"
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import core.time : msecs;
import std.stdio : writeln;
import vibe.core.core : sleep;
void main() @system
{
foreach (tick; 1 .. 4) { sleep(10.msecs); writeln("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 3#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_eh_callback_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.loop : DefaultLoop;
import sparkles.event_horizon.op : Completion;
struct Timer
{
int ticks;
int error;
}
void tick(ref Timer timer, ref Completion done) nothrow @nogc
{
if (done.res < 0) timer.error = done.res;
else ++timer.ticks;
}
int main() @system
{
Timer timer; // Outlives the loop, including cleanup after a drive error.
DefaultLoop loop;
auto started = DefaultLoop.create(loop);
if (started.hasError) { stderr.writeln(started.error); return 1; }
scope(exit) loop.destroy();
foreach (number; 1 .. 4)
{
auto armed = loop.submitAfter!tick(10.msecs, timer);
if (armed.hasError) { stderr.writeln(armed.error); return 1; }
auto ran = loop.run();
if (ran.hasError) { stderr.writeln(ran.error); return 1; }
if (timer.error) { stderr.writeln("timer failed: ", timer.error); return 1; }
writeln("tick ", timer.ticks);
}
return 0;
}tick 1
tick 2
tick 3Contract examples: vibe-core · event-horizon fibers · event-horizon callbacks.
Concurrency and joined lifetime
Start independent work and combine its results as 1 * 10 + 2. Both fiber programs join both tasks before retrieving or reporting failures. This is concurrency, not evidence of simultaneous execution on multiple CPU cores.
Keep task handles and callback contexts alive through completion. An EH fork routes its typed failure to its join handle, not directly into the scope policy. The example explicitly promotes joined failures with root.fail. Vibe-core's Future.getResult propagates task exceptions after joining.
The callback program admits two operations before driving the loop. Their result slots provide explicit fan-in; there is no implicit lexical child scope. The assertion-heavy fiber fixture retains the ready/release handshake that proves overlap independently of timer scheduling.
#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_vibe_concurrency"
dependency "vibe-core" version="2.14.0"
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import core.time : msecs;
import std.stdio : writeln;
import vibe.core.concurrency : async;
import vibe.core.core : sleep;
void main() @system
{
auto slow = async({ sleep(20.msecs); return 1; });
auto fast = async({ sleep(5.msecs); return 2; });
// Join both before retrieving values, which can propagate task exceptions.
slow.task.join();
fast.task.join();
writeln("joined: ", slow.getResult() * 10 + fast.getResult());
}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: 12#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_eh_callback_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.loop : DefaultLoop;
import sparkles.event_horizon.op : Completion;
struct Job { int input, result, error; }
void completed(ref Job job, ref Completion done) nothrow @nogc
{
if (done.res < 0) job.error = done.res;
else job.result = job.input;
}
int main() @system
{
Job[2] jobs = [Job(1), Job(2)]; // Outlive the loop and all its callbacks.
DefaultLoop loop;
auto started = DefaultLoop.create(loop);
if (started.hasError) { stderr.writeln(started.error); return 1; }
scope(exit) loop.destroy();
foreach (ref job; jobs)
{
auto armed = loop.submitAfter!completed(10.msecs, job);
if (armed.hasError) { stderr.writeln(armed.error); return 1; }
}
auto ran = loop.run(); // Explicit fan-in; not a structured fiber scope.
if (ran.hasError) { stderr.writeln(ran.error); return 1; }
foreach (job; jobs)
if (job.error) { stderr.writeln("timer failed: ", job.error); return 1; }
writeln("joined: ", jobs[0].result * 10 + jobs[1].result);
return 0;
}joined: 12Contract examples: vibe-core · event-horizon fibers · event-horizon callbacks.
Cancellation, deadlines and cleanup
The examples distinguish timeout detection from successful completion and print whether cleanup ran. Read the foreign operation's interruption mechanism carefully: cancelling a registration, signalling a flag and interrupting a fiber are not interchangeable. A timeout on a wait does not necessarily stop the operation being waited for.
Event Horizon's deadline latches cooperative cancellation. The parked sleep returns ECANCELED; protect lets cleanup cross checkpoints even with that latch set. Keep cleanup bounded. The foreign tab documents its own status representation rather than pretending its errors are event-horizon values.
The callback deadline requests cancellation of an already-admitted long timer, observes its terminal -ECANCELED, and only then submits asynchronous cleanup. run() drains that cleanup before the context dies. This is a manually owned deadline state machine, not withDeadline or a callback-level protected scope. The fixture's long delay makes the intended cancellation path testable; real code must also handle completion winning the race.
#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_vibe_deadline"
dependency "vibe-core" version="2.14.0"
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import core.time : msecs, seconds;
import std.stdio : writeln;
import vibe.core.concurrency : async;
import vibe.core.core : setTimer, sleep, sleepUninterruptible;
import vibe.core.task : InterruptException;
void main() @system
{
bool cleaned;
auto work = async({
scope(exit) { sleepUninterruptible(5.msecs); cleaned = true; }
try { sleep(10.seconds); return false; }
catch (InterruptException) { return true; }
});
auto timer = setTimer(50.msecs, () nothrow { work.task.interrupt(); });
scope(exit) timer.stop();
const interrupted = work.getResult(); // Other task exceptions propagate.
writeln("sleep returned: ", interrupted ? "InterruptException" : "ok");
writeln("timed out: ", interrupted);
writeln("cleaned up: ", cleaned);
}sleep returned: InterruptException
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: true#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_eh_callback_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.loop : DefaultLoop;
import sparkles.event_horizon.op : Completion, OpHandle;
struct State
{
DefaultLoop* loop;
OpHandle operation;
bool timedOut, finished, cleaned, stopping;
int result, error;
}
void cleaned(ref State state, ref Completion done) nothrow @nogc
{
if (done.res < 0) state.error = done.res;
else state.cleaned = true;
}
void finished(ref State state, ref Completion done) nothrow @nogc
{
if (state.stopping) return;
state.finished = true;
state.result = done.res;
// Cleanup starts only after terminal completion, not after cancel().
auto armed = state.loop.submitAfter!cleaned(5.msecs, state);
if (armed.hasError) state.error = -armed.error.code;
}
void deadline(ref State state, ref Completion done) nothrow @nogc
{
if (state.stopping) return;
if (done.res < 0) { state.error = done.res; return; }
if (state.finished) return;
state.timedOut = true;
auto cancelled = state.loop.cancel(state.operation);
if (cancelled.hasError) state.error = -cancelled.error.code;
}
int main() @system
{
State state;
DefaultLoop loop;
auto started = DefaultLoop.create(loop);
if (started.hasError) { stderr.writeln(started.error); return 1; }
scope(exit) { state.stopping = true; loop.destroy(); }
state.loop = &loop;
auto work = loop.submitAfter!finished(10.seconds, state);
if (work.hasError) { stderr.writeln(work.error); return 1; }
state.operation = work.value;
auto timer = loop.submitAfter!deadline(50.msecs, state);
if (timer.hasError) { stderr.writeln(timer.error); return 1; }
auto ran = loop.run();
if (ran.hasError) { stderr.writeln(ran.error); return 1; }
if (state.error || (state.result < 0 && state.result != -ECANCELED))
{ stderr.writeln("operation failed: ", state.error, " / ", state.result); return 1; }
writeln("sleep returned: ", state.result == -ECANCELED ? "ECANCELED" : "ok");
writeln("timed out: ", state.timedOut);
writeln("cleaned up: ", state.cleaned);
return 0;
}sleep returned: ECANCELED
timed out: true
cleaned up: trueContract examples: vibe-core · event-horizon fibers · event-horizon callbacks.
TCP and buffer ownership
The tutorial sends the five-byte message hello and prints the echoed frame. TCP is a byte stream: neither one write nor one readiness/completion notification is a message boundary. Accumulate partial receives and retain unsent suffixes, or use an API's documented all-bytes operation. Binding loopback port zero lets the OS reserve a port for this run without a port-selection race.
The foreign callbacks borrow buffers according to their library's contract. Copy data that must outlive a callback, or retain the documented owner. Event Horizon moves an owned buffer into the operation and returns ownership on completion. That lifetime discipline is not a promise of kernel or network zero-copy. A real protocol also needs framing, maximum message sizes and per-connection deadlines.
The callback tutorial sets up a local TCP pair synchronously, then drives all data transfers through OpSend and OpRecv. Its five-byte framing state handles short completions. Contexts and fixed buffers outlive the loop; teardown prevents callbacks from rearming. The contract example additionally tests async accept/connect and deliberately tiny pool buffers.
#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_vibe_tcp_echo"
dependency "vibe-core" version="2.14.0"
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import std.stdio : writeln;
import vibe.core.net : connectTCP, listenTCP, TCPConnection;
import vibe.core.sync : createManualEvent;
void main() @system
{
auto finished = createManualEvent();
Exception serverError;
auto listener = listenTCP(0, (TCPConnection peer) nothrow @safe {
scope(exit) finished.emit();
scope(exit) peer.close();
try
{
ubyte[5] frame;
peer.read(frame[]); // Five-byte framing; all-mode handles short reads.
peer.write(frame[]);
}
catch (Exception error) { serverError = error; }
}, "127.0.0.1");
scope(exit) listener.stopListening();
auto client = connectTCP("127.0.0.1", listener.bindAddress.port);
scope(exit) client.close();
ubyte[5] reply;
try
{
client.write(cast(const(ubyte)[]) "hello");
client.read(reply[]);
}
finally
{
client.close(); // Unblock the server even if the exchange failed.
finished.waitUninterruptible(0); // Includes an earlier completion emit.
}
if (serverError !is null) throw serverError;
writeln("echoed: ", cast(const(char)[]) reply[]);
}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: hello#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_eh_callback_tcp_echo"
dependency "sparkles:event-horizon" path="../../../../../.."
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import core.lifetime : move;
import core.stdc.errno : EIO;
import std.socket : TcpSocket, InternetAddress;
import std.stdio : writeln, stderr;
import sparkles.event_horizon.loop : DefaultLoop;
import sparkles.event_horizon.op : Completion, OpRecv, OpSend;
import sparkles.event_horizon.buffer : Buf;
struct Transfer
{
DefaultLoop* loop;
bool* stopping;
int fd;
bool sending;
size_t used;
int error;
ubyte[5] bytes;
}
void next(ref Transfer transfer) nothrow @nogc
{
if (*transfer.stopping) return;
auto window = Buf.fromForeign(transfer.bytes[transfer.used .. $], null);
window.length = cast(uint) (transfer.bytes.length - transfer.used);
if (transfer.sending)
{
auto sent = transfer.loop.submit!completed(OpSend(transfer.fd, move(window)), transfer);
if (sent.hasError) transfer.error = sent.error.code;
}
else
{
auto received = transfer.loop.submit!completed(OpRecv(transfer.fd, move(window)), transfer);
if (received.hasError) transfer.error = received.error.code;
}
if (transfer.error) { *transfer.stopping = true; transfer.loop.stop(); }
}
void completed(ref Transfer transfer, ref Completion done) nothrow @nogc
{
if (*transfer.stopping) return; // Also prevents rearming during teardown.
if (done.res <= 0)
{
transfer.error = done.res < 0 ? -done.res : EIO;
*transfer.stopping = true;
transfer.loop.stop();
return;
}
transfer.used += done.res;
if (transfer.used < transfer.bytes.length) next(transfer);
}
int main() @system
{
// Synchronous loopback fixture setup. Data transfers use Tier-A callbacks.
auto listener = new TcpSocket;
scope(exit) listener.close();
listener.bind(new InternetAddress("127.0.0.1", 0));
listener.listen(1);
auto client = new TcpSocket;
scope(exit) client.close();
client.connect(listener.localAddress);
auto server = listener.accept();
scope(exit) server.close();
bool stopping;
Transfer outgoing, incoming;
DefaultLoop loop;
auto started = DefaultLoop.create(loop);
if (started.hasError) { stderr.writeln(started.error); return 1; }
scope(exit) { stopping = true; loop.destroy(); }
outgoing.loop = incoming.loop = &loop;
outgoing.stopping = incoming.stopping = &stopping;
outgoing.fd = cast(int) client.handle;
incoming.fd = cast(int) server.handle;
outgoing.sending = true;
outgoing.bytes[] = cast(const(ubyte)[]) "hello";
foreach (exchange; 0 .. 2)
{
next(outgoing);
if (!outgoing.error) next(incoming);
auto ran = loop.run(); // Fan-in for this five-byte exchange.
if (ran.hasError) { stderr.writeln(ran.error); return 1; }
if (outgoing.error || incoming.error)
{ stderr.writeln("transfer failed: ", outgoing.error, " / ", incoming.error); return 1; }
outgoing.bytes = incoming.bytes;
outgoing.used = incoming.used = 0;
outgoing.fd = cast(int) server.handle;
incoming.fd = cast(int) client.handle;
}
writeln("echoed: ", cast(const(char)[]) incoming.bytes[]);
return 0;
}echoed: helloContract examples: vibe-core · event-horizon fibers · event-horizon callbacks.
Queues, channels and backpressure
The shared payload contract is the ordered sequence 1 through 5, with sum 15. The flow-control contracts can differ. Both tutorials let a producer fill a capacity-two channel while a consumer drains it. The standalone EH contract separately proves that a third put parks and one freed slot permits exactly one additional put, without relying on a short sleep as evidence.
Read the foreign implementation before translating put: an unbounded queue does not acquire a capacity bound merely because the consumer is slow. A callback adapter must stop producing and arrange a wakeup when space returns; blocking an event-loop thread on an OS semaphore can stop unrelated connections. Event Horizon's Channel!(T, capacity) belongs to one scheduler and is not a drop-in cross-thread notification or broadcast mechanism.
#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_vibe_channel"
dependency "vibe-core" version="2.14.0"
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import std.stdio : writeln;
import vibe.core.channel : createChannel;
import vibe.core.concurrency : async;
void main() @system
{
auto items = createChannel!(int, 2)();
auto producer = async({
scope(exit) items.close();
foreach (value; 1 .. 6) items.put(value); // Parks while full.
return 0;
});
int sum, next;
while (items.tryConsumeOne(next)) sum += next; // Ends after close and drain.
producer.getResult(); // Join and propagate errors.
writeln("consumed: ", sum);
}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: vibe-core · event-horizon fibers.
Files and operation lifetime
The tutorials create an anonymous temporary file containing hello from a file\n. The vibe-core example sizes and reads a bounded span; the EH fiber helper reads through EOF with a byte limit. Both print the contents. Keep file and buffer ownership until all operations finish. Never classify a read failure as EOF.
The event-horizon example reads until EOF using offsets and an owned buffer. Its Linux io_uring implementation submits file operations to the kernel. This does not establish the same behavior for every backend: kqueue synthesizes completions over readiness and its regular-file worker-pool refinement is separate. Where the foreign example uses a worker adapter, it is explicitly application code, not evidence of a native filesystem capability.
The callback tutorial reads an anonymous temporary fixture through positioned OpRead operations until zero-byte EOF, using a fixed bound. Fixture creation and final close are synchronous setup/teardown. The contract version separately tests asynchronous open/close, missing-file errors and tiny pooled buffers. The fiber tutorial opens the file through env.fs and reads it with bounded readText; the file closes itself.
#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_vibe_file_read"
dependency "vibe-core" version="2.14.0"
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import std.conv : to;
import std.stdio : File, writeln;
import std.exception : enforce;
import vibe.core.file : FileMode, openFile;
void main() @system
{
auto fixture = File.tmpfile();
fixture.write("hello from a file\n");
fixture.flush();
auto file = openFile("/proc/self/fd/" ~ fixture.fileno.to!string, FileMode.read);
scope(exit) file.close();
ubyte[1024] bytes;
const length = cast(size_t) file.size;
enforce(length <= bytes.length, "file exceeds the example's bound");
file.read(bytes[0 .. length]); // Default mode reads the whole requested span.
writeln("read ", length, " bytes: ", cast(const(char)[]) bytes[0 .. length]);
}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 file#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_eh_callback_file_read"
dependency "sparkles:event-horizon" path="../../../../../.."
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import core.lifetime : move;
import core.stdc.errno : EFBIG;
import std.stdio : File, writeln, stderr;
import sparkles.event_horizon.loop : DefaultLoop;
import sparkles.event_horizon.op : Completion, OpRead;
import sparkles.event_horizon.buffer : Buf;
struct Reader
{
DefaultLoop* loop;
int fd, error;
bool stopping;
size_t used;
ubyte[128] bytes; // A bounded example, not an unbounded file loader.
}
void readNext(ref Reader reader) nothrow @nogc
{
if (reader.used == reader.bytes.length) { reader.error = EFBIG; return; }
auto window = Buf.fromForeign(reader.bytes[reader.used .. $], null);
auto submitted = reader.loop.submit!readDone(OpRead(reader.fd, move(window), reader.used), reader);
if (submitted.hasError) reader.error = submitted.error.code;
}
void readDone(ref Reader reader, ref Completion done) nothrow @nogc
{
if (reader.stopping) return;
if (done.res < 0) reader.error = -done.res;
else if (done.res > 0) { reader.used += done.res; readNext(reader); }
// Zero is EOF. A failed read is not EOF.
}
int main() @system
{
auto fixture = File.tmpfile(); // Synchronous fixture setup; reads below are native callbacks.
fixture.write("hello from a file\n");
fixture.flush();
Reader reader;
DefaultLoop loop;
auto started = DefaultLoop.create(loop);
if (started.hasError) { stderr.writeln(started.error); return 1; }
scope(exit) { reader.stopping = true; loop.destroy(); }
reader.loop = &loop;
reader.fd = fixture.fileno;
readNext(reader);
auto ran = loop.run();
if (ran.hasError) { stderr.writeln(ran.error); return 1; }
if (reader.error) { stderr.writeln("read failed: ", reader.error); return 1; }
writeln("read ", reader.used, " bytes: ", cast(const(char)[]) reader.bytes[0 .. reader.used]);
return 0;
}read 18 bytes: hello from a fileContract examples: vibe-core · event-horizon fibers · event-horizon callbacks.
Capturing a child process
The child writes different text to stdout and stderr, then exits with code 3. Both streams and the nonzero status are printed: a child command failing is not necessarily failure to launch or observe it. Root reaping is part of the operation's lifetime.
A general capture implementation must drain both pipes concurrently, even when one is quiet. Reading all stdout before stderr can deadlock when the stderr pipe fills. Tiny fixed fixtures do not prove a hand-written adapter safe for unbounded output. capture supplies the drain/reap coordination; adapters need their own output limits, cancellation and error handling.
#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_vibe_exec"
dependency "vibe-core" version="2.14.0"
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import core.time : seconds;
import std.stdio : writeln;
import vibe.core.concurrency : async;
import vibe.core.process : pipeProcess, Redirect;
import vibe.core.stream : IOMode;
string drain(Stream)(Stream stream)
{
string text;
ubyte[1024] bytes;
while (!stream.empty)
text ~= cast(const(char)[]) bytes[0 .. stream.read(bytes[], IOMode.once)];
return text;
}
void main() @system
{
auto pipes = pipeProcess(["sh", "-c", "echo out; echo err >&2; exit 3"], Redirect.all);
pipes.stdin.close();
auto stdout = async(() => drain(pipes.stdout));
auto stderr = async(() => drain(pipes.stderr));
const status = pipes.process.waitOrForceKill(5.seconds);
stdout.task.join();
stderr.task.join(); // Both streams must drain concurrently.
writeln("stdout: ", stdout.getResult());
writeln("stderr: ", stderr.getResult());
writeln("exit code: ", status);
}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: vibe-core · event-horizon fibers.
Streaming and termination
Streaming adds a state machine: accumulate bytes into lines, observe the root, request termination, finish drains, and reap. A pipe read can split a line or contain several lines, so splitting each chunk independently is insufficient. The two source tabs expose the cleanup machinery instead of hiding it behind a printed success message.
The foreign program's manual root termination is not process-tree containment. Event Horizon reports supervision outcome and reap provenance, but its process group/cgroup tiers are not an inescapable sandbox either: descendants can race the post-spawn cgroup migration. EOF may be forced at the drain limit. See the supervision caveats.
#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_vibe_spawn"
dependency "vibe-core" version="2.14.0"
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import core.time : seconds;
import std.stdio : writeln;
import std.string : indexOf;
import vibe.core.concurrency : async;
import vibe.core.process : pipeProcess, Redirect;
import vibe.core.stream : IOMode;
void main() @system
{
auto pipes = pipeProcess(["sh", "-c", "echo one; echo two; exec sleep 30"], Redirect.stdout);
auto reader = async({
string pending;
ubyte[128] bytes;
while (!pipes.stdout.empty)
{
pending ~= cast(const(char)[]) bytes[0 .. pipes.stdout.read(bytes[], IOMode.once)];
for (auto end = pending.indexOf('\n'); end >= 0; end = pending.indexOf('\n'))
{
const line = pending[0 .. end];
writeln("line: ", line);
if (line == "two") pipes.process.kill(); // TERM after readiness.
pending = pending[end + 1 .. $];
}
}
if (pending.length) writeln("line: ", pending);
return 0;
});
const status = pipes.process.waitOrForceKill(5.seconds); // Bounded root wait.
reader.getResult();
writeln("exited: signaled by ", status < 0 ? -status : 0);
writeln("end: stopped, reap: reaped");
}line: one
line: two
exited: signaled by 15
end: stopped, reap: reaped#!/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: vibe-core · event-horizon fibers.
POSIX signals and loop notifications
The foreign tab uses sigwait in an ordinary worker and then delivers completion through its runtime bridge. Unlike calling an event method from a signal handler, this does not assume the event method is async-signal-safe. The signal is thread-directed for an isolated, reproducible fixture.
Install the signal receiver before sending SIGUSR1, then print the received signal. Linux signal masks and handler installation are process/thread integration decisions; they are not interchangeable with an ordinary callback queue. Keep signal-handler work async-signal-safe.
SignalFd receives masked POSIX signals through the event-horizon loop. A foreign notification primitive may only wake an owner loop from another thread. In particular, libasync's AsyncSignal is not a POSIX signal subscription; an OS signal bridge must supply that integration explicitly. These programs are Linux fixtures, not Windows signal portability examples.
#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_vibe_signal"
dependency "vibe-core" version="2.14.0"
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import core.sys.posix.signal : sigset_t, sigemptyset, sigaddset, SIGUSR1,
pthread_sigmask, SIG_BLOCK, SIG_SETMASK, pthread_kill, sigwait;
import core.sys.posix.pthread : pthread_self;
import core.thread : Thread;
import std.exception : enforce;
import std.stdio : writeln;
import vibe.core.sync : createSharedManualEvent;
void main() @system
{
auto completed = createSharedManualEvent();
Exception failure;
int received;
// POSIX adapter: sigwait lives on an OS thread, not in a signal handler.
auto worker = new Thread({
scope(exit) completed.emit();
try
{
sigset_t signals, previous;
enforce(sigemptyset(&signals) == 0 && sigaddset(&signals, SIGUSR1) == 0);
enforce(pthread_sigmask(SIG_BLOCK, &signals, &previous) == 0);
scope(exit) enforce(pthread_sigmask(SIG_SETMASK, &previous, null) == 0);
enforce(pthread_kill(pthread_self(), SIGUSR1) == 0);
enforce(sigwait(&signals, &received) == 0);
}
catch (Exception error) { failure = error; }
});
worker.start();
completed.wait(0); // The waiting fiber parks.
worker.join();
if (failure !is null) throw failure;
writeln("got signal ", received == SIGUSR1 ? "SIGUSR1" : "other");
}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: vibe-core · event-horizon fibers.
Retries and ordinary errors
The synthetic operation fails transiently twice, then succeeds. The tutorial prints the successful attempt; the standalone contract checks the exact count and result.
Retry policy needs a retryable-error decision and a finite budget. Vibe-core's loop catches only the example's TransientFailure; other exceptions propagate. EH's fiber policy retries ordinary I/O failures and preserves interruption and defects as separate channels. Neither shape makes repeated side effects safe unless the operation is idempotent.
The callback version handles EAGAIN with 5 ms then 10 ms backoff and a finite attempt budget. Admission and timer-completion errors are reported separately, not mistaken for retryable dependency failures.
#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_vibe_retry"
dependency "vibe-core" version="2.14.0"
platforms "linux"
buildType "checked" {
buildOptions "optimize" "inline" "debugInfo"
}
+/
import core.time : msecs;
import std.stdio : writeln;
import vibe.core.core : sleep;
class TransientFailure : Exception
{
this() @safe { super("try again"); }
}
void main() @system
{
foreach (attempt; 1 .. 6)
{
try
{
if (attempt < 3) throw new TransientFailure;
writeln("succeeded on attempt ", attempt);
break;
}
catch (TransientFailure error)
{
if (attempt == 5) throw error;
sleep((5 << (attempt - 1)).msecs);
}
}
}succeeded on attempt 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 3#!/usr/bin/env dub
/+ dub.sdl:
name "tutorial_eh_callback_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.loop : DefaultLoop;
import sparkles.event_horizon.op : Completion;
import sparkles.event_horizon.errors : IoResult, OpKind, ioOk, ioErr;
struct State { DefaultLoop* loop; int attempts, error; bool stopping; }
IoResult!int request(int attempt) @safe nothrow @nogc
=> attempt < 3 ? ioErr!int(EAGAIN, OpKind.none) : ioOk(42);
void attempt(ref State state, ref Completion done) nothrow @nogc
{
if (state.stopping) return;
if (done.res < 0) { state.error = done.res; return; }
auto result = request(++state.attempts);
if (!result.hasError) return;
if (result.error.code != EAGAIN || state.attempts >= 5)
{ state.error = -result.error.code; return; }
auto timer = state.loop.submitAfter!attempt((5 << (state.attempts - 1)).msecs, state);
if (timer.hasError) state.error = -timer.error.code;
}
int main() @system
{
State state;
DefaultLoop loop;
auto started = DefaultLoop.create(loop);
if (started.hasError) { stderr.writeln(started.error); return 1; }
scope(exit) { state.stopping = true; loop.destroy(); }
state.loop = &loop;
auto first = loop.submitAfter!attempt(0.msecs, state);
if (first.hasError) { stderr.writeln(first.error); return 1; }
auto ran = loop.run();
if (ran.hasError) { stderr.writeln(ran.error); return 1; }
if (state.error) { stderr.writeln("request failed: ", state.error); return 1; }
writeln("succeeded on attempt ", state.attempts);
return 0;
}succeeded on attempt 3Contract examples: vibe-core · event-horizon fibers · event-horizon callbacks.
Errors and migration decisions
| Boundary | Migration decision |
|---|---|
| Ordinary operation failure | Check the foreign status/exception and the event-horizon IoResult; do not silently treat failure as success or EOF. |
| Task failure | Decide who observes the error and who joins remaining work. Scope outcomes and defects are distinct from ordinary I/O errors. |
| Buffer lifetime | Retain borrowed data until the foreign operation finishes; recover moved buffers from event-horizon completions. |
| Cross-thread work | Use a documented cross-thread bridge. Scheduler-local channels do not replace it. |
| Missing native primitive | Treat a Phobos/POSIX adapter as application integration, not as a feature supplied by the networking library. |
Next steps
Start with one independently owned connection or operation. Preserve its framing, error handling and shutdown behavior before changing scheduler topology. The Node.js comparison provides another fully executable view of the same concerns, including explicit retries. The execution guide records platform and containment limits.