Skip to content

Running the comparison examples ​

Each of the eight migration guides compares ten concepts using complete source files under docs/libs/event-horizon/tutorial/snippets/. The ten Event Horizon fiber programs are shared; each library has its own ten counterparts. The eventcore and vibe-core guides additionally share six native callback programs, bringing the original contract suite to 96 programs. Separate concise tutorials are being added alongside them: 36 currently cover the Node.js and vibe-core guides. The eight articles retain 172 displayed outputs in total. VitePress shows complete files, each with a separately verified output tab.

The D files are single-file DUB packages. Node.js uses native .mjs files with node:assert/strict in the contract suite, not pseudo-JavaScript or a D translation. Native-library operations and application-side adapters are distinguished in the articles.

Prerequisites and commands ​

These fixtures target Linux with a usable io_uring backend. They use the current repository through a relative DUB dependency, not a published package version. The signal and process examples also require normal POSIX facilities and sh. The comparison verifier requires Node.js 24 and GNU coreutils timeout. Run from a checkout with the project's D toolchain available:

bash
dub run --single apps/ci/tools/prepare-event-horizon-comparisons.d --temp-build -b checked
dub run --single docs/libs/event-horizon/tutorial/snippets/tutorial/eh_timers.d -b checked
node docs/libs/event-horizon/tutorial/snippets/tutorial/node_timers.mjs
# The standalone contract version remains runnable too:
dub run --single docs/libs/event-horizon/tutorial/snippets/eh_timers.d -b checked
dub run :ci -- --verify --include-files 'docs/libs/event-horizon/tutorial/coming-from-*.md'

Run a D source from its original location: its relative dependencies resolve from that file. Copying only its body into an unrelated directory omits the package recipe and changes that resolution. No line-sliced imports are used.

Dependency baselines ​

CounterpartRecipe baselineIntegration notes
Node.jsTested runtime: 24.19.0Native promise, stream, process and signal APIs; no npm packages.
vibe-core2.14.0Uses the package's eventcore dependency.
eventcore0.9.39Default Linux driver; not a certification of every selectable driver.
libasync0.9.8Linux callback API; explicit worker adapters for processes and POSIX signals.
Photon0.19.3initPhoton, scheduler, channels and offloaded worker adapters.
EVEc72f75135de636987e91bcd8ec5a22d32d34a197Public Codeberg Git dependency; not a personal checkout path.
Collief1e58e38a2c36366766e4778d3ea655ebac6962c (0.10.16)Isolated compatibility checkout; low-level event examples use its Kiss engine.
Kiss6d07c263c2b9bdec493996b7f4cedb95b5812271 (0.4.9)Explicit transitive override for Collie, prepared below.
Hunt-net0.7.1Hunt Task/queue adapters are not native structured supervision.
Hunt5264f181088fb04cea5702ccdeab0fd5e1c8486d (1.7.17)Explicit transitive override for Hunt-net, with the flag-array bounds fix.
event-horizonThis Sparkles checkoutRequires usable Linux io_uring; no silent fallback or skip.

The preparation command clones Collie, Kiss, and Hunt into the ignored snippets/.deps/ directory and applies checked-in patches. It does not edit registered DUB packages or the source checkouts used for research. Collie's patch parenthesizes assignment expressions and adds a missing timer import. Kiss's patch sizes the flag array to include index 16 (ETMode), fixes an eight-byte timerfd read into a four-byte variable, closes its wakeup channel during selector disposal, and avoids allocating a log message from GC finalization. Hunt's patch likewise includes flag index 16 in its array. These are patched baselines, not claims that the original releases pass unchanged. Re-running preparation checks the pinned revisions.

Hunt's standalone fixtures also stop and join its startup DateTime daemon before creating application threads. The pinned graph otherwise leaves that daemon alive during runtime shutdown. This explicitly asserted, process-owned adapter is explained in the Hunt-net guide; it is not advice to stop arbitrary host threads when embedding the library.

The local compiler baseline is LDC 1.42.0 (D 2.112). Exact direct recipe versions and the source-review revisions serve different purposes: a linked research revision does not replace the dependency actually resolved by DUB. Transitive registry dependencies retain their upstream constraints; clean-cache verification checks the resulting graph rather than assuming local-cache success is enough.

What the verifier guarantees ​

Migration pages are being split into two suites. The Node.js and vibe-core pages display concise executable programs from snippets/tutorial/ and links the original assertion-heavy programs in snippets/ after each comparison. The other six pages still display their original contract programs until their conversion is complete. Both kinds are standalone single-file programs, not source fragments.

For a converted page, ci --verify executes each displayed tutorial, checks its output, and independently runs its contract counterpart with assertions enabled. It rejects missing counterparts, mixed suites, symlink aliases and attempts to revert a converted page to contract imports. Full source paths—not basenames— identify programs, so a passing contract cannot stand in for a failing tutorial. Only displayed tutorial output blocks are candidates for --update.

Each coming-from-*.md page must contain all ten concepts, with full-file source imports and adjacent ansi [implementation output] tabs. The eventcore and vibe-core pages additionally require callback and fiber implementations for timers, concurrency, deadlines, TCP, file reads and retries. Missing styles or pairs, missing or ambiguous outputs, missing sources, sliced imports and paths escaping the documentation tree fail validation. Quoted Markdown is not executed. Imported outputs use literal comparisons (including empty output); legacy wildcard matching does not weaken this contract.

The verifier builds D programs in checked mode in isolated DUB/TMP directories, then runs the cached artifacts so compiler diagnostics do not become expected program output. It executes each shared source once per invocation but compares every displayed output occurrence independently. Node.js runs through the same exit-status and process-group deadline checks. A stuck launcher and its ordinary descendants receive TERM, then KILL after two seconds. This is not containment for a deliberately escaping descendant.

SPARKLES_CI_JOBS controls parallel builds; SPARKLES_CI_EXAMPLE_TIMEOUT sets the per-build/per-run deadline in seconds (strict comparisons retain a finite default even when the legacy timeout setting is disabled). To regenerate output after an intentional source change, use the same command with --update instead of --verify. It updates Markdown output spans, never imported source files.

checked keeps assertions enabled in the contract suite. Tutorials instead handle expected failures and report errors at the application boundary. An unavailable backend is an explicit failure, not silently reported as a successful demonstration.

The default LoopGroup topology is one scheduler on the calling thread. Fibers can run concurrently without running simultaneously on multiple CPU cores. Do not share scheduler-local channels or handles across worker threads.

Completion interface, backend-specific implementation ​

Linux io_uring submits operations and reports their completion. Event Horizon's kqueue backend synthesizes completions over readiness; it does not turn kqueue into an io_uring-style ring. Backend support is not identical, and the Windows supervision port remains deferred. Consult PLAN.md before relying on an operation outside the tested Linux path.

Moving a buffer transfers its ownership through an operation. It does not prove zero-copy transport, eliminate every alias, or make the whole application @nogc. Setup, callbacks, and integrations can allocate or throw. Blocking host work needs an explicit worker boundary; a fiber does not make arbitrary blocking calls cooperative.

I/O results versus scope outcomes ​

ResultWhat to check
IoResult!ThasError before reading value; errors carry errno, operation, and stage.
Outcome!TScope success, failure, interruption, or defect; do not discard the outer group.run result.
Process resultInspect both the I/O result and exit status; a nonzero child exit is normal result data.
Channel receiveEPIPE means closed and drained; cancellation is not normal end-of-stream.

Joining structured children does not preempt CPU-bound code. A deadline is cooperative and can take longer than its requested duration while children and protected cleanup finish. See the scope and cancellation specification.

Deadlines and cleanup ​

The sleep is interrupted, but the small protected cleanup completes before the deadline outcome is returned.

d
#!/usr/bin/env dub
/+ dub.sdl:
    name "eh_deadline"
    dependency "sparkles:event-horizon" path="../../../../.."
    buildType "checked" {
        buildOptions "optimize" "inline" "debugInfo"
    }
    platforms "linux"
+/
import core.time : msecs, seconds;
import core.stdc.errno : ECANCELED;
import std.stdio : writeln;
import sparkles.event_horizon;

void main()
{
    LoopGroup group;
    if (LoopGroup.start(group).hasError)
        assert(false, "no completion backend");
    scope (exit) group.shutdown();

    auto run = group.run((ref RootScope root, ref Env env) {
        ref Sched s = currentScheduler();
        bool cleanedUp;
        auto outcome = withDeadline!((ref sc) {
            auto slept = env.clock.sleep(10.seconds); // interrupted at 50 ms
            writeln("sleep returned: ", slept.hasError ? "ECANCELED" : "ok");
            assert(slept.hasError && slept.error.code == ECANCELED);
            // Cleanup runs to completion even though the fiber is cancelled.
            cast(void) protect!(() {
                assert(!env.clock.sleep(5.msecs).hasError);
                cleanedUp = true;
                return 0;
            })(s);
            return 0;
        })(s, 50.msecs);
        assert(outcome.hasError && outcome.error.isTimeout);
        assert(cleanedUp);
        writeln("timed out: ", outcome.hasError && outcome.error.isTimeout);
        writeln("cleaned up: ", cleanedUp);
        return 0;
    });
    assert(run.hasValue);
}
sleep returned: ECANCELED
timed out: true
cleaned up: true

TCP is a byte stream ​

Neither a send nor a receive is guaranteed to transfer an entire application message. The echo fixture uses an ephemeral loopback port and loops over partial transfers. Its five-byte exchange is a fixture protocol, not a general decoder.

d
#!/usr/bin/env dub
/+ dub.sdl:
    name "eh_tcp_echo"
    dependency "sparkles:event-horizon" path="../../../../.."
    buildType "checked" {
        buildOptions "optimize" "inline" "debugInfo"
    }
    platforms "linux"
+/
import core.lifetime : move;
import std.stdio : writeln;
import sparkles.base.buffer : UniqueBuffer;
import sparkles.event_horizon;

void main()
{
    LoopGroup group;
    if (LoopGroup.start(group).hasError)
        assert(false, "no completion backend");
    scope (exit) group.shutdown();

    auto run = group.run((ref RootScope sc, ref Env env) {
        auto listening = env.net.listen(ipv4("127.0.0.1", 0));
        assert(listening.hasValue);
        auto listener = move(listening.value);
        scope (exit) listener.close();
        const port = boundPort(listener);

        // The server is a joined child, handling one five-byte exchange.
        assert(sc.spawn({
            auto conn = listener.accept; // parks until a client arrives
            assert(conn.hasValue);
            echo(conn.value);
        }));

        // The client, in the same loop.
        auto connected = env.net.connect(ipv4("127.0.0.1", port));
        assert(connected.hasValue);
        auto client = move(connected.value);
        scope (exit) client.close();
        sendText(client, "hello");

        UniqueBuffer!(ubyte, 64) back;
        string response;
        while (response.length < 5)
        {
            back.length = 64;
            auto got = client.recv(move(back));
            back = move(got.buf);
            assert(got.res.hasValue && got.res.value > 0);
            response ~= cast(const(char)[]) back[][0 .. got.res.value];
        }
        assert(response == "hello");
        writeln("echoed: ", response);
        return 0;
    });
    assert(run.hasValue);
}

void echo(Stream conn)
{
    scope (exit) conn.close();
    UniqueBuffer!(ubyte, 4096) buf;
    size_t received;
    while (received < 5)
    {
        buf.length = 4096;
        auto r = conn.recv(move(buf));
        buf = move(r.buf);
        assert(r.res.hasValue && r.res.value > 0);
        received += r.res.value;
        sendText(conn, cast(const(char)[]) buf[][0 .. r.res.value]);
    }
}

// Copies the unsent suffix into an owned buffer on each attempt. This is a
// small correctness example, not a claim of zero-copy networking.
void sendText(ref Stream conn, scope const(char)[] bytes)
{
    size_t offset;
    while (offset < bytes.length)
    {
        UniqueBuffer!(ubyte, 64) buf;
        buf ~= cast(const(ubyte)[]) bytes[offset .. $];
        auto sent = conn.send(move(buf));
        assert(sent.res.hasValue && sent.res.value > 0);
        offset += sent.res.value;
    }
}

ushort boundPort(ref Listener l) @trusted
{
    import core.sys.posix.arpa.inet : ntohs;
    import core.sys.posix.netinet.in_ : sockaddr_in;
    import core.sys.posix.sys.socket : getsockname, sockaddr, socklen_t;

    sockaddr_in a;
    socklen_t len = a.sizeof;
    assert(getsockname(l.fd, cast(sockaddr*) &a, &len) == 0);
    return ntohs(a.sin_port);
}
echoed: hello

Capture versus streaming supervision ​

Use capture to collect stdout/stderr and the root's exit status. Use supervise for framed output callbacks, resource samples, and termination policy. Callback line bytes are borrowed: copy them if they must outlive the callback.

d
#!/usr/bin/env dub
/+ dub.sdl:
    name "eh_spawn"
    dependency "sparkles:event-horizon" path="../../../../.."
    buildType "checked" {
        buildOptions "optimize" "inline" "debugInfo"
    }
    platforms "linux"
+/
import core.time : msecs;
import std.stdio : writeln;
import sparkles.event_horizon;

void main()
{
    LoopGroup group;
    if (LoopGroup.start(group).hasError)
        assert(false, "no completion backend");
    scope (exit) group.shutdown();

    auto run = group.run((ref RootScope sc, ref Env env) {
        ref Sched s = currentScheduler();
        SupervisedProcessConfig cfg;
        cfg.terminateGrace = 500.msecs; // cancellation: TERM, then bounded KILL grace
        cfg.process.stdinSpec = StdioSpec(StdioMode.nullDev);
        int linesSeen;
        SupervisedProcessResult result;
        bool returned;
        auto scopeResult = withScope!((ref outer) {
            JoinHandle!void task;
            outer.fork(task, () {
                auto got = supervise(s, ["sh", "-c", "echo one; echo two; exec sleep 30"], cfg,
                    null, (in ProcessEvent ev) {
                        final switch (ev.kind)
                        {
                        case ProcessEventKind.line:
                            assert(cast(const(char)[]) ev.line.bytes == (linesSeen == 0 ? "one" : "two"));
                            writeln("line: ", cast(const(char)[]) ev.line.bytes);
                            ++linesSeen;
                            if (linesSeen == 2) outer.cancel(); // readiness handshake, no startup-time race
                            break;
                        case ProcessEventKind.exited:
                            writeln("exited: ", ev.end, ", signaled by ",
                                ev.status.signaled ? ev.status.code : 0);
                            break;
                        case ProcessEventKind.sample:
                            break; // periodic resource samples; not printed here
                        }
                    });
                assert(got.hasValue);
                result = got.value;
                returned = true;
                return ioOk();
            });
            task.join(s);
        })(s);
        assert(!scopeResult.hasError && returned);
        assert(linesSeen == 2);
        assert(result.end == ProcessEnd.cancelled);
        assert(result.reap == ReapOutcome.reaped);
        assert(result.status.signaled && result.status.code == 15);
        assert(!result.eofForced);
        writeln("end: ", result.end, ", reap: ", result.reap);
        return 0;
    });
    assert(run.hasValue);
}

The sample requests cancellation only after receiving both expected lines, then asserts a cancelled outcome, root reap status, SIGTERM and natural EOF for this specific Linux fixture. exec sleep avoids a shell descendant retaining the pipe. The deadline example separately demonstrates timeout delivery. General supervision may escalate to KILL and report degraded containment; this small fixture does not prove those paths or portable signal numbers.

Tree containment is capability-dependent. Linux cgroups strengthen the boundary, but post-spawn migration can race a child that forks immediately; process groups do not contain a descendant that creates another session. Degradation and leaked cgroup telemetry remain meaningful results, not guarantees to omit from a caller.

POSIX signals are not cross-thread messages ​

Create SignalFd before starting other threads that should inherit the blocked signal mask. This fixture sends SIGUSR1 to itself, then consumes it through the scheduler. It is distinct from a library's in-process notification object.

d
#!/usr/bin/env dub
/+ dub.sdl:
    name "eh_signal"
    dependency "sparkles:event-horizon" path="../../../../.."
    buildType "checked" {
        buildOptions "optimize" "inline" "debugInfo"
    }
    platforms "linux"
+/
import core.sys.posix.signal : SIGUSR1, kill;
import core.sys.posix.unistd : getpid;
import std.stdio : writeln;
import sparkles.event_horizon;

void main()
{
    SignalFd signals;
    if (SignalFd.create(signals, [SIGUSR1]).hasError)
        assert(false, "signalfd unavailable");

    LoopGroup group;
    if (LoopGroup.start(group).hasError)
        assert(false, "no completion backend");
    scope (exit) group.shutdown();

    auto run = group.run((ref RootScope sc, ref Env env) {
        ref Sched s = currentScheduler();
        assert(kill(getpid(), SIGUSR1) == 0);
        auto got = signals.nextSignal(s); // parks until the signal completes
        assert(got.hasValue && got.value == SIGUSR1);
        writeln("got signal ", got.value == SIGUSR1 ? "SIGUSR1" : "other");
        return 0;
    });
    assert(run.hasValue);
}
got signal SIGUSR1

Retry with a clock capability ​

This policy combines exponential delays with a finite recurrence limit. The example uses portable EAGAIN rather than a Linux numeric literal.

d
#!/usr/bin/env dub
/+ dub.sdl:
    name "eh_retry"
    dependency "sparkles:event-horizon" path="../../../../.."
    buildType "checked" {
        buildOptions "optimize" "inline" "debugInfo"
    }
    platforms "linux"
+/
import core.time : msecs;
import core.stdc.errno : EAGAIN;
import std.stdio : writeln;
import sparkles.event_horizon;

void main()
{
    LoopGroup group;
    if (LoopGroup.start(group).hasError)
        assert(false, "no completion backend");
    scope (exit) group.shutdown();

    auto run = group.run((ref RootScope sc, ref Env env) {
        ref Sched s = currentScheduler();
        int attempts;
        auto outcome = withScope!((ref inner) {
            enum policy = exponential(5.msecs) & recurs(4);
            auto r = retry(inner, env.clock, policy, () {
                ++attempts;
                if (attempts < 3)
                    return ioErr!int(EAGAIN, OpKind.none, IoErrorStage.submit,
                        "flaky");
                return ioOk(attempts);
            });
            return r.hasValue ? r.value : -1;
        })(s);
        assert(outcome.hasValue && outcome.value == 3);
        writeln("succeeded on attempt ", outcome.value);
        return 0;
    });
    assert(run.hasValue);
}
succeeded on attempt 3

Return to the library overview to choose a source-library guide.