Serving Varlink¶
sdvarlink's varlink package serves interfaces as well as calling them. A
Server holds interfaces; each interface is registered with its IDL text; each
method gets a typed Go handler. The server answers introspection, validates
parameters against the IDL and behaves like systemd's own sd-varlink, so
varlinkctl and systemd's tooling treat a Go service exactly like a C one.
//go:embed io.example.Queue.varlink
var queueIDL string
srv := varlink.NewServer(varlink.ServerInfo{
Vendor: "Example", Product: "queued", Version: "1.2.0", URL: "https://example.com",
})
iface := srv.Interface("io.example.Queue", queueIDL)
varlink.Handle(iface, "Depth", func(ctx context.Context, call *varlink.Call, _ struct{}) (DepthOut, error) {
return DepthOut{Jobs: q.Len()}, nil
})
l, err := varlink.Listen("/run/queued/io.example.Queue")
if err != nil {
return err
}
return srv.Serve(ctx, l) // returns after ctx ends and in-flight calls drain
How a call is served¶
sequenceDiagram
participant C as Client
participant R as Connection reader
participant D as Dispatcher
participant H as Your handler
C->>R: {"method":"io.example.Queue.Depth","parameters":{}}\0
R->>D: queue the call (pipelined calls wait in order)
D->>D: find interface and method, check flags
D->>D: validate parameters against the IDL
D->>H: Handle(ctx, call, in)
H-->>D: out, err
D-->>C: {"parameters":{"jobs":3}}\0
Each connection has a reader goroutine and a dispatcher. Calls on one
connection run one at a time, in order, so a client may pipeline calls and get
replies back in the same order. Different connections run concurrently. The
reader keeps reading while a handler runs, so a client that disconnects
cancels its handler's ctx right away. A client that only closes its write
side after sending its calls (as socat or a shell pipe does) is still
answered; the connection closes once its queued calls are done.
Registering interfaces and handlers¶
srv.Interface(name, idl) parses the IDL. The text you pass is exactly what
GetInterfaceDescription returns, so introspection always matches what you
serve. Embed it from a .varlink file next to your code.
Handlers are generic functions, checked against the IDL when you register them:
| Function | Use it for | Handler signature |
|---|---|---|
varlink.Handle |
Methods with one reply | func(ctx, *varlink.Call, In) (Out, error) |
varlink.HandleStream |
Methods that stream (more) |
func(ctx, *varlink.Call, In, send func(Out) error) error |
In and Out are structs whose JSON field names match the IDL. Use struct{}
for methods without parameters or reply fields. The usual mapping applies:
?T fields are pointers (or slices and maps) with omitempty, enums are
strings, object and any are json.RawMessage.
Fail at startup, not in production
Registration panics when the handler cannot serve the method: the method is
not in the IDL, a Go field is not in the IDL, a field's Go type disagrees
with its IDL type, the reply type lacks a required field, or a method that
requires more is registered with Handle. Mismatches never reach
clients.
Methods declared in the IDL without a handler answer
org.varlink.service.MethodNotImplemented, so you can serve an interface
before implementing all of it.
Serving a systemd interface with generated types¶
The generated packages work on the server side too: their <Method>In and
<Method>Out types are your handler's types, and InterfaceDescription is the
IDL to register.
import "github.com/rossedman/sdvarlink/systemd/v260/metrics"
iface := srv.Interface(metrics.InterfaceName, metrics.InterfaceDescription)
varlink.HandleStream(iface, "List", func(ctx context.Context, call *varlink.Call, _ struct{}, send func(metrics.ListOut) error) error {
return send(metrics.ListOut{Name: "io.example.App.requests", Value: json.RawMessage("42")})
}, varlink.LastIsFinal())
See Publish metrics for a complete
io.systemd.Metrics provider.
Streaming replies¶
A HandleStream handler gets a send function. Which mode to use depends on
whether the stream is a finite list or an open-ended feed:
Each send writes a reply with "continues": true immediately. When the
handler returns, an empty final reply ends the stream. Use this for
subscriptions and event feeds, where each event must reach the client as
soon as it happens.
varlink.HandleStream(iface, "Watch", func(ctx context.Context, call *varlink.Call, in WatchIn, send func(WatchOut) error) error {
for {
select {
case ev := <-events:
if err := send(WatchOut{Event: ev}); err != nil {
return err // the client is gone
}
case <-ctx.Done(): // client disconnected or server shutting down
return nil
}
}
})
With varlink.LastIsFinal(), each reply is held until the next send (or
the handler's return) shows whether more follow, so the last value is the
final reply. This is what sd-varlink does for list replies, and it means
clients never see a trailing empty item. Use it for methods such as
io.systemd.Metrics.List that return a known set of results.
Only send while the handler runs
send fails once the handler has returned, so a goroutine the handler
started cannot write into a later call's replies. Wait for such
goroutines before returning.
If the handler's ctx ends (the client disconnected or the server is
shutting down) and it returns, the server closes the connection without a
final reply, as sd-varlink does, rather than end the stream with a reply
the client did not expect.
A call to a streaming method without the more flag gets
org.varlink.service.ExpectedMore. Add varlink.AllowSingle() to answer such
calls with the first value sent instead. Methods the IDL marks
# [Requires 'more' flag] always need more.
Errors¶
Return an error from a handler and the server turns it into a Varlink error:
| Handler returns | Client receives |
|---|---|
*varlink.Error{Name, Parameters} |
that error, as is |
A type with VarlinkError() string, such as a generated *resolve.NoNameServersError |
that error name, with the value's JSON fields as parameters |
varlink.ErrPermissionDenied (and the other org.varlink.service sentinels) |
the standard error |
An error wrapping a syscall.Errno |
io.systemd.System with errno and errnoName |
| Any other error, or a panic | io.systemd.System with EIO. The error and stack are logged; nothing leaks to the client |
The last two rows match sd-varlink, which reports failing method callbacks as
io.systemd.System errors. sdvarlink's own client unwraps them, so
errors.Is(err, syscall.EIO) works on the calling side.
The server answers these before your handler runs:
| Condition | Error |
|---|---|
| Interface not registered | org.varlink.service.InterfaceNotFound with interface |
| Method not in the IDL | org.varlink.service.MethodNotFound with method |
| Method in the IDL, no handler | org.varlink.service.MethodNotImplemented with method |
| Missing required field, wrong type, unknown enum value, or a field the IDL does not define | org.varlink.service.InvalidParameter with parameter |
Streaming method called without more |
org.varlink.service.ExpectedMore |
upgrade requested |
org.varlink.service.MethodNotImplemented |
Unknown fields are rejected, as sd-varlink does. Register a method with
varlink.AllowUnknownFields() to accept them, for example while clients roll
out a parameter your IDL does not have yet.
Authorization with peer credentials¶
The server adds no access control of its own; your handler decides. call.Peer()
returns the caller's UID, GID and PID, read once per connection with
SO_PEERCRED. Authorize on UID and GID; PIDs are informational because they
are reused.
varlink.Handle(iface, "Enqueue", func(ctx context.Context, call *varlink.Call, in EnqueueIn) (EnqueueOut, error) {
peer, err := call.Peer()
if err != nil {
return EnqueueOut{}, err
}
if peer.UID != 0 && int(peer.UID) != os.Getuid() {
return EnqueueOut{}, varlink.ErrPermissionDenied
}
// ...
})
Combine this with socket permissions (SocketMode=, SocketGroup= in a
.socket unit, or varlink.WithSocketMode and varlink.WithSocketOwner) so
that only the right users can connect at all.
File descriptors¶
Handlers send and receive files the same way clients do: *os.File fields
tagged varlink:"name,fd" in In and Out.
- Files a handler receives are its to close.
- Files in a reply are closed by the server once they are sent, including when
the client has gone away. A reply with more files than
WithServerMaxFilesallows is refused before anything is written: the call getsio.systemd.SystemEIOand the connection stays up. - Descriptors no field claims are closed; the per-message limit is
varlink.WithServerMaxFiles(default 16).
Sockets and lifecycle¶
The same binary should work as a socket-activated unit and from a terminal. Prefer the socket systemd passes, and fall back to binding the path:
l, err := varlink.ListenActivated("")
if errors.Is(err, varlink.ErrNotActivated) {
l, err = varlink.Listen("/run/queued/io.example.Queue")
}
varlink.Listen(path, opts...)creates missing parent directories (0755), replaces a stale socket file left by a crashed run, refuses a path where another server is listening, and sets the socket mode (default0660) and optional owner. The socket is created with its final permissions in a private directory and renamed into place (except for a path within about 15 bytes of the socket path limit, which is bound in place), and a.<name>.lockfile next to it serializes concurrent starts. The socket file is removed when the server stops.varlink.ListenActivated(name)adopts a socket from$LISTEN_FDS. With several sockets,namepicks one by itsFileDescriptorName=, so one daemon can serve, say, an API socket and a/run/systemd/report/metrics socket. Activated sockets belong to systemd and are left in place.- Any
*net.UnixListenerworks withServe, which keeps tests simple.
# queued.socket
[Socket]
ListenStream=/run/queued/io.example.Queue
SocketMode=0660
SocketGroup=queue-admins
# queued.service
[Service]
ExecStart=/usr/bin/queued
Shutdown¶
flowchart LR
A["ctx passed to Serve ends"] --> B["listener closes:<br/>no new connections"]
B --> C["streaming handlers'<br/>ctx is cancelled;<br/>their connections close"]
B --> D["in-flight calls finish<br/>(grace period, default 5 s)"]
D --> E["remaining connections<br/>closed"]
C --> E
E --> F["socket file removed<br/>(unless activated)"]
F --> G["Serve returns nil"]
Connections take no new calls once shutdown begins, even calls a client
already pipelined. Handlers that ignore their context after the grace period
cannot be stopped; Serve logs a warning and returns without them.
Limits and options¶
| Option | Default | Effect |
|---|---|---|
WithServerMaxFrameSize(n) |
16 MiB | Larger calls get io.systemd.System ENOBUFS and the connection closes |
WithServerMaxFiles(n) |
16 | Descriptors accepted with one read, and sent with one reply |
WithMaxConnections(n) |
256 | Connections beyond this are closed on accept. Temporary accept failures (such as running out of descriptors) are retried with backoff |
WithIdleTimeout(d) |
off | Close connections with no call in flight for d |
WithShutdownGrace(d) |
5 s | How long in-flight calls may run after shutdown begins |
WithLogger(l) |
slog.Default() |
Where handler errors, panics and protocol problems go. Parameters are never logged |
Testing a server¶
Serve takes any Unix listener, so a test can run the real server on a
temporary socket and call it with the real client:
func TestDepth(t *testing.T) {
srv := newServer() // registers interfaces and handlers
path := filepath.Join(t.TempDir(), "queue")
l, err := varlink.Listen(path)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go srv.Serve(ctx, l)
c, err := varlink.Dial(ctx, path)
if err != nil {
t.Fatal(err)
}
defer c.Close()
var out DepthOut
if err := c.Call(ctx, "io.example.Queue.Depth", nil, &out); err != nil {
t.Fatal(err)
}
}
Socket path length
Unix socket paths are limited to 108 bytes, and t.TempDir() can exceed
that for long test names. Use os.MkdirTemp("", "x") if you hit
bind: invalid argument.
varlinktest remains the tool for faking a service your code calls; the
Server is for code that serves.
See also¶
- Example: a job queue service
- Example: publish metrics
- Errors for the client side of error handling
varlinkon pkg.go.dev