Chapter 11 / 51
Volume 2 · Configuration & Assembly

Multiple services & topology: one process, a whole service map

services[] doesn't have to hold just one. Real deployments often mix "an HTTP site farm + WebSocket push + a TCP gateway + UDP heartbeats" — all sharing one engine, one set of script capabilities and one graceful shutdown.

Overview

Positioning: from "configure one service" to "understand the whole topology". This chapter also introduces the runtime topology query APIs — the raw material of chapter 40's management-plane plugin.

11.1 Intro

The naive way to deploy multiple services is "one process per kind" — the cost is process sprawl, chaotic port planning and scattered logs. XServer's topology model is multiple servers in one process: one xs.json describes every endpoint, one process hosts every service — sharing the engine thread pool, the script runtime capabilities and a single graceful shutdown. The production home repository runs exactly this shape: one XServer process, 14 vhosts, http and static sites mixed.

11.2 One process, many protocols

Mixed topology example
{
  "engine": { "workers": 8 },
  "services": [
    { "class": "http", "name": "web",  "port": 80,  "tls": true, "port_tls": 443,
      "host_default": { "name": "app", "path": "wwwroot", "devlang": "c", "devfile": "main.c" } },
    { "class": "ws",   "name": "push", "port": 8081,
      "host_default": { "name": "app", "devlang": "c", "devfile": "push_main.c" } },
    { "class": "tcp",  "name": "gw",   "port": 9000, "tls": true, "port_tls": 9443,
      "devlang": "c", "devfile": "gw_main.c" },
    { "class": "udp",  "name": "beat", "port": 9010,
      "devlang": "c", "devfile": "beat_main.c" }
  ]
}

What just happened: four services, four scripts; at assembly every candidate must compile before any bind happens — a single compile failure refuses the whole startup (the same discipline as hot reload's "compile before swap"). Each service swaps generations independently (reload publishes per server) without affecting the others.

11.3 engine.workers

  • The one process-level knob: 0–1024, 0 = xrt default (12 on this machine in the demo)
  • Every server's callbacks share this one pool — HTTP requests, WS messages, TCP data and UDP datagrams all run on the workers
  • Immutable across hot reload: a reload intent changing workers is rejected (thread count is a process-level fact)
  • Capacity estimate: callbacks are short event handlers; workers ≈ CPU cores is a sane start; long blocking work belongs on its own thread (chapter 41), not in bigger workers

11.4 Runtime topology queries

Cross-service collaboration: finding the http service from the ws service
/* enumerate all services from the push service's script */
static bool PrintTopo(XS_ServerInfo* pServer, void* pUserData)
{
    printf("- %s (%s) :%u%s\n", pServer->Name, pServer->Class,
        pServer->Port, pServer->TLS ? " +tls" : "");
    return true;
}
xsEnumServers(PrintTopo, NULL);

/* take a retained lease by name (must Release) */
XS_ServerInfo* pWeb = xsServerFind("web");
if ( pWeb != NULL ) {
    XS_HostInfo* pHost = xsHostFind(pWeb, "app");
    /* ... read topology info across services ... */
    xsServerRelease(pWeb);
}

The read side of the topology is guarded by an RWLock (many reads, few writes) and the Accept path enters the read side every time — queries are cheap. Enumeration arguments are borrowed inside the callback; lease discipline lives in the reference.

11.5 Contract

  • Assembly: with multiple services, every candidate compiles before any bind; any failure rejects the whole startup.
  • Swap: reload publishes per server; xsReloadAllSubmit is a batch transaction (all succeed or all roll back).
  • Queries: xsServerFind returns a retained lease; enumeration arguments are borrowed for the callback — Retain before storing.

Common mistakes

Trying to hot-change workers
Symptom
The reload intent FAILS; Message says workers is immutable.
Cause
The thread pool is process-level; rebuilding it at runtime would change the concurrency semantics of every callback — rejected outright rather than risked.
Holding a cross-service raw pointer across generations
Symptom
After reloading the web service, an XS_ServerInfo* cached in the push service becomes a wild pointer.
Cause
Pointers from enumeration callbacks are borrowed for one round; keeping them across generations/services requires Retain into a lease.

Exercises

BasicA four-protocol mix

Run the 11.2 topology (scripts may export nothing each) and draw the topology table from the boot log.

Acceptance: the log has server/bound lines for all four services with ports matching the config.
AdvancedA topology export route

Add a /topo route to the web service and emit the JSON topology via xsEnumServers.

Acceptance: the output carries name/class/port/tls for every service.
ChallengeA single-service failure experiment

Break the gw script's compile on purpose and start; observe. Then fix gw and break web's script instead.

Acceptance: you can describe the boot-log shape under "any candidate fails" and explain why "let the three good ones run first" is not allowed.

Summary

PointConclusion
TopologyMultiple servers per process mixing five protocols, sharing the engine and shutdown
AssemblyAll candidates compile before bind; any failure rejects the whole startup
workersProcess-level, shared by callbacks, immutable across reload; start at ≈CPU cores
QueriesxsServerFind (lease) / xsEnumServers (borrowed)
xrt further reading