4. Headless services

A headless Zinc program has no window and no zinc:gfx. The event loop stays alive as long as there is work to do — an open server socket, a poller, a pending timer or microtask — so a daemon is just a program that opens a listener and/or arms a timer and never runs out of work. When there is nothing left, the loop returns and the program exits.

The worked example is examples/service/sensor-hub: it reads a sensor every second, serves the values as JSON over HTTP, publishes to MQTT when configured, and exports metrics to zinc:telemetry. Run it:

zinc run examples/service/sensor-hub                 # sim (Node)
zinc run examples/service/sensor-hub --target macos  # native
curl localhost:3000/readings                         # {"seq":…,"tempC":…,"humidity":…}

HTTP server

zinc:net gives you serve(port, handler) — a minimal HTTP/1.1 server whose handler runs on the event loop (no threads, one connection at a time drained by the poller). Bind is 0.0.0.0 (ZINC_BIND=127.0.0.1 restricts it, e.g. behind a reverse proxy). Requests are limited: 16 KiB of headers, a 1 MiB body (ZRT_HTTP_MAX_BODY), 32 open connections, 10 s to send a request, 2 s for a client to take a reply; see security about exposure.

import { serve, Request, Reply } from 'zinc:net';
serve(3000, (req: Request): Reply => {
  if (req.path === '/healthz') return { status: 200, contentType: 'application/json', body: '{"ok":true}' };
  return { status: 404, contentType: 'application/json', body: '{"error":"not found"}' };
});

fetch(url, init?) is the client side (libcurl on hosts, esp_http_client on ESP32). Only http: and https: URLs are fetched, following at most 20 redirects; on hosts, a redirect to another scheme, host or port drops the Authorization, Proxy-Authorization and Cookie headers. A request times out after timeoutMs (default 120000; connecting takes at most 30 s). A response body larger than maxBytes (default: a quarter of the heap, at most 64 MiB) is refused with fetch failed: response too large. The handler receives method, path, body; there is no routing framework — branch on req.path yourself.

MQTT, OSC, telemetry

import { MqttClient } from 'zinc:mqtt';           // MQTT 3.1.1, QoS 0
const c = new MqttClient('broker', 1883, 'client-id');
c.connect().then(() => c.publish('sensors/hub', '{"t":21.4}'));
c.subscribe('cmd/#', (topic, payload) => console.log(topic, payload));

import { send, listen } from 'zinc:osc';           // OSC 1.0 over UDP
send('127.0.0.1', 9000, '/level', [0.8]);
listen(9001, (m) => console.log(m.address, m.numbers));

zinc:telemetry streams JSON lines (perf, logs, metrics, exposed state) to UDP/stdout/file; zinc monitor renders them live. Enable with the ZINC_TELEMETRY env var (no code change) or telemetry.connect(...):

import * as telemetry from 'zinc:telemetry';
telemetry.gauge('sensor.tempC', t);
telemetry.counter('sensor.samples', 1);
telemetry.expose('tempC', () => latest);           // sampled at 10 Hz into state snapshots
ZINC_TELEMETRY=udp://127.0.0.1:9999 zinc run examples/service/sensor-hub --target macos &
zinc monitor          # fps/metrics/exposed vars, live

Timers

setTimeout / setInterval / clearTimeout / clearInterval work headless — the loop wakes for the next due timer. A setInterval is enough to keep a daemon alive with no listener.

Logging

console.log/info/debug/warn/error/trace map to leveled output; warn/error/trace go to stderr. Set ZINC_LOG_FORMAT=json for one JSON object per line ({"time","level","message"}), ready for journald/Loki:

ZINC_LOG_FORMAT=json ./sensor-hub
# {"time":361126276.226,"level":"INFO","message":"sensor-hub listening on http://0.0.0.0:3000 (mqtt off)"}

Signals and shutdown

On POSIX hosts (macos, linux, rpi1, rmpp), sys.onSignal runs a handler on the event loop when a signal arrives. The handler replaces the default action, which would terminate the program:

import * as sys from 'zinc:sys';
import { stop } from 'zinc:net';
sys.onSignal('SIGTERM', () => {       // systemctl stop, docker stop
  console.log('draining…');
  stop();                             // no new connections; the loop ends when the last work is done
});
sys.onSignal('SIGINT', () => { sys.exit(130); });   // Ctrl-C
  • Delivery. The OS handler only sets a flag; a poller calls your callbacks between tasks. This means a handler can do anything a timer callback can.
  • Liveness. Handlers do not keep the program alive, the same as process.on('SIGINT') in Node.
  • Names. SIGINT, SIGTERM, SIGHUP, SIGUSR1, SIGUSR2, SIGQUIT, SIGWINCH, SIGALRM, SIGCHLD, SIGPIPE, SIGCONT and SIGTSTP. SIGKILL cannot be caught.
  • Sending. sys.kill(pid, 'SIGTERM') sends a signal, and sys.pid() is this process.
  • Other targets. ESP32, PS1, PS2 and wasm have no signals: there onSignal does nothing.

Fatal faults (SIGSEGV, SIGBUS, SIGFPE…) keep going to the crash policy below. sys.exit(code) still ends the process at once.

Process, files, sockets

NeedModule
Arguments, environment, working directory, pid, stdout without a newline, stdinzinc:sys: args, env / setEnv / unsetEnv / envKeys, cwd / chdir, pid, write / writeErr, isatty, onStdin(cb) (chunks, '' at EOF)
Where the user ran the program from, the user’s languagezinc:sys: launchDir() (zinc run <project> starts the program in the project, so resolve a relative path given as an argument against launchDir(), not cwd()), locale() (a BCP 47 tag such as fr-FR: LC_ALL, LC_MESSAGES or LANG when set, else the system’s first preferred language, which an app started from the Finder has without any variable)
Files and directorieszinc:fs: text and bytes, stat / lstat, readDir (with types), mkdir(p, true) (mkdir -p), remove(p, true) (rm -rf), rename, copyFile, realpath, mkdtemp / tmpdir, symlink / readlink, chmod, watch (polling, 100 ms)
Machine informationzinc:os: hostname, homedir, tmpdir, arch, type, release, uptime, loadavg, totalmem / freemem, cpus, networkInterfaces, userInfo
Child processeszinc:process (docs): spawn with stdin / stdout / stderr pipes, kill, exit code
TCP / UDP / Unix sockets, DNS, WebSocketzinc:socket (docs)
Pathszinc:path: POSIX join, resolve, relative, dirname, basename, extname, parse, format (Node’s results)
SQL databasezinc:sqlite (docs)
Web APIs (fetch, URL, crypto, events…)globals (chapter 9)

Running as a systemd service

zinc export --target linux (or rpi1) produces dist/<name>-<target>/ with the executable, a run.sh, a <name>.service unit (Restart=on-failure, RestartSec=2) and a deploy.sh that installs it root-owned in /opt/<name>, installs the unit and starts it. The unit runs the program unprivileged (DynamicUser=yes, no capabilities, read-only system, private /tmp): its working directory and zinc:storage file are in /var/lib/<name> (StateDirectory), and devices (framebuffer, input, GPIO, SPI, I2C) are reached through the groups the device has (SupplementaryGroups, filtered by deploy.sh). Details and how to relax it: chapter 8.

zinc export --target linux examples/service/sensor-hub
dist/sensor-hub-linux/deploy.sh me@server        # copies, installs the unit, enable --now
# or, or by hand on the box:
sudo cp sensor-hub.service /etc/systemd/system/ && sudo systemctl enable --now sensor-hub
journalctl -u sensor-hub -f                       # pair with ZINC_LOG_FORMAT=json in the unit's Environment=

Watchdog / restart policy

Two independent layers:

  • systemd restarts the process if it exits non-zero (Restart=on-failure). This is your production watchdog.
  • zinc.json "crash" decides what a crash inside the program does before the process ends: exit (default — die, let systemd restart), redbox (draw the error and wait; on a headless service redbox behaves as restart), or restart (re-run init/teardown in place; three crashes within five seconds gives up with exit 101). For a daemon, "crash": "restart" recovers from transient panics without a process bounce; exit + systemd is the simplest robust choice.

Resource budgets

The heap is a fixed TLSF region sized by the profile (macos/linux 512 MiB, rpi1 64 MiB, esp32 160 KiB); override with zinc.json targets.<id>.heap. Watch live usage with sys.liveObjects() / sys.allocations(), or expose them via telemetry. Build with --debug to get ASan + a leak report while developing (see testing).

On ESP32 as firmware

The same source builds as firmware — net (fetch), mqtt, osc, telemetry, storage (NVS), fs (SPIFFS) and gpio (driver/gpio) all have ESP32 backends. Swap the fake sample() for a real GPIO/I2C read or a native module. WiFi credentials go in zinc.json targets.esp32.wifi ({ssid, password}); note that esp_wifi station bring-up is not implemented yet and there is no HTTP server on ESP32 (fetch/MQTT/OSC clients only) — see ADR 0011 for exactly what is wired and what is a stub.

zinc build examples/service/sensor-hub --target esp32     # ESP-IDF firmware (built in docker)
zinc export --target esp32 examples/service/sensor-hub    # + flash.sh (esptool)

Signals between objects (zinc:signals)

Qt’s signals and slots, typed and without a meta-object system: an object exposes Signal<T> fields, anyone connects a function, emit calls the slots in connection order.

import { Signal, Trigger } from 'zinc:signals';

class Thermometer {
  readonly reading = new Signal<number>();   // a signal with a value
  readonly failed = new Trigger();           // a signal without one
}
const t = new Thermometer();
const c = t.reading.connect((celsius: number) => console.log(`${celsius} °C`));
t.reading.emit(21.5);          // direct: the slots run now (Qt::DirectConnection)
t.reading.emitQueued(22);      // queued: on the next microtask (Qt::QueuedConnection)
const next = await t.reading.next();   // a promise of the next value
c.disconnect();                // or `using c = t.reading.connect(...)` to disconnect at the end of the scope

A slot disconnected during an emit is not called afterwards; one connected during an emit waits for the next one; once() connects for a single emit. In a UI, fromSignal(signal, initial) from zinc:ui/solid turns it into a reactive accessor (disconnected with its owner). zinc:events’ Emitter remains the tool for values coming from native threads (listeners run as microtasks).

Next: plugins. Web platform APIs: chapter 9.

Edit this page on GitHub ↗