A self-orchestrated microservices demo, entirely in PatLang

One orchestrator process spawns three independent PatLang service processes as real, separate OS processes, waits for each to confirm it's genuinely ready (not just "the process started" — its own presence beacon has to be live), discovers what each one actually offers rather than being told in advance, and dispatches real requests purely by matching capability — no hardcoded ports, no hardcoded knowledge of which process does what. Everything used here is infrastructure already built earlier in the same arc (presence, discovery, and non-blocking spawn), combined for the first time into one real, runnable system.

orchestrator
spawns, waits for readiness, discovers, dispatches, shuts down
↓ spawn() ↓
greeteroffers: greet
calculatoroffers: add, multiply
clockoffers: time
↑ announce / discover ↑
presence beacon (file-backed message queue) — each service announces itself here; the orchestrator reads it to find who can do what, with no prior knowledge of any service's port or existence

Why this can't run live in this page

Every other interactive demo on this site runs in the browser, compiled to WebAssembly. This one can't: spawn() launches a real, separate operating-system process, and there is no browser/WASM equivalent of that at all — a WASM module can't start another program on your machine, for the same sandboxing reasons nothing else on the web can either. Running several independent PatLang programs side by side within one browser tab, talking to each other via postMessage instead of real sockets, was considered and rejected for this page specifically — it would be simulating the same idea with different, weaker infrastructure than the real one, not demonstrating the real one. What follows instead is the actual source code and a real, unedited transcript captured running this on a real machine.

The source, in full

One worker executable serving three possible roles (picked via a command-line argument), plus the orchestrator. Nothing here is new machinery — every host function and library call was already built and tested earlier in this arc.

The worker (self_hosting/examples/microservice_worker.patlang)

include "../lib/signal_discovery.patlang"

let args = argv()
let role = args[0]
let task_name = "svc_" + role

# `when` handlers must be at genuine top level -- registering one
# conditionally inside an `if role == ...` block was tried first and
# found NOT to work: it silently never fires, no error, just a dead
# handler. So every handler is registered unconditionally; a given
# process only ever actually receives the signal matching its own role.
when quit do
  set_var("should_stop", "1")
  signal_reply("bye")
end

when greet do
  signal_reply("Hello, " + event_data + "! (from the greeter microservice)")
end

when add do
  let parts = sd_split(event_data, ",")
  signal_reply("" + (to_num(str_trim(parts[0])) + to_num(str_trim(parts[1]))))
end

when multiply do
  let parts = sd_split(event_data, ",")
  signal_reply("" + (to_num(str_trim(parts[0])) * to_num(str_trim(parts[1]))))
end

when time do
  signal_reply("current server time: " + now_ms() + "ms since epoch")
end

let port_id = signal_claim(0)
if port_id >= 0 then
  if role == "greeter" then
    signal_announce(task_name, port_id, [["greet", "greets whoever you send, payload = a name"]])
  else
    if role == "calculator" then
      signal_announce(task_name, port_id, [["add", "adds two numbers, payload = 'a,b'"], ["multiply", "multiplies two numbers, payload = 'a,b'"]])
    else
      if role == "clock" then
        signal_announce(task_name, port_id, [["time", "reports the current server time"]])
      end
    end
  end
  set_var("should_stop", "0")
  while get("__vars", "should_stop") == "0" do
    signal_poll(port_id, 300)
  end
end

The orchestrator (self_hosting/examples/microservices_demo.patlang)

include "../lib/signal_discovery.patlang"

make a function called wait_for_ready takes task_name, pid, timeout_ms returns result
  # polls is_alive(pid) AND signal_discover_one(task_name, ...) together --
  # neither alone can distinguish "still starting up" from "already died"
  let waited = 0
  let looping = true
  let outcome = ["timeout", "", []]
  while looping do
    if not is_alive(pid) then
      let outcome = ["crashed", "", []]
      let looping = false
    else
      let found = signal_discover_one(task_name, 60000)
      if found[0] != "" then
        let outcome = ["ready", found[0], found[1]]
        let looping = false
      else
        if waited >= timeout_ms then
          let looping = false
        else
          sleep_ms(100)
          let waited = waited + 100
        end
      end
    end
  end
  return outcome
end

make a function called spawn_service takes role returns pid_and_result
  let pid = spawn(pat_path(), "--ir-run", worker_path(), role)
  let result = wait_for_ready("svc_" + role, pid, 5000)
  return [pid, result]
end

make a function called dispatch takes keyword, payload, description returns done
  let candidates = signal_find_capable(keyword, 60000)
  if to_num(list_len(candidates)) == 0 then
    log_line(description + " -- no service currently offers '" + keyword + "'")
    return true
  end
  let match = candidates[0]
  signal_wrap(match[0], match[1], [[match[2], ""]])
  let reply = signal_call(match[0], match[2], payload)
  log_line("  -> reply: " + reply)
  return true
end

let greeter = spawn_service("greeter")
let calculator = spawn_service("calculator")
let clock = spawn_service("clock")

dispatch("greet", "Pat", "asked who can 'greet'")
dispatch("add", "17,25", "asked who can 'add'")
dispatch("multiply", "6,7", "asked who can 'multiply'")
dispatch("time", "", "asked who can report 'time'")
dispatch("translate", "hello", "asked who can 'translate'")   # an honest miss

signal_send(to_num(greeter[1][1]), "quit", "")
signal_send(to_num(calculator[1][1]), "quit", "")
signal_send(to_num(clock[1][1]), "quit", "")
wait(greeter[0])
wait(calculator[0])
wait(clock[0])

(Trimmed for readability above — the real source has the full logging around every step. See the repository for the complete files.)

A real, captured transcript

Unedited output from actually running the orchestrator, timestamps shown as milliseconds elapsed since the run started rather than raw epoch numbers. Confirmed identical, byte for byte in content, whether run interpreted (pat --ir-run) or compiled natively via the self-hosted compiler (patc1.exe) — and confirmed clean across multiple repeated runs, not just one lucky pass.

[+0ms] orchestrator: starting -- spawning 3 independent microservices
[+7ms] spawned 'greeter' service (pid 63352)
[+108ms] 'greeter' is ready on port 62353 -- offers: greet
[+113ms] spawned 'calculator' service (pid 48556)
[+215ms] 'calculator' is ready on port 62354 -- offers: add, multiply
[+221ms] spawned 'clock' service (pid 61152)
[+323ms] 'clock' is ready on port 62355 -- offers: time
[+323ms] orchestrator: dispatching real requests, purely by discovered capability
[+324ms] asked who can 'greet' -- found 'svc_greeter' offers 'greet', calling it
[+330ms]   -> reply: Hello, Pat! (from the greeter microservice)
[+331ms] asked who can 'add' -- found 'svc_calculator' offers 'add', calling it
[+334ms]   -> reply: 42
[+335ms] asked who can 'multiply' -- found 'svc_calculator' offers 'multiply', calling it
[+336ms]   -> reply: 42
[+337ms] asked who can report 'time' -- found 'svc_clock' offers 'time', calling it
[+338ms]   -> reply: current server time: 1784311111704ms since epoch
[+338ms] orchestrator: demonstrating an honest miss -- nobody offers this
[+340ms] asked who can 'translate' -- no service currently offers 'translate'
[+340ms] orchestrator: shutting down all services
[+351ms] orchestrator: done -- all services exited cleanly

Worth a second look: add(17, 25) and multiply(6, 7) both land on 42 in this particular run — a genuine coincidence of the example numbers chosen (17+25=42, 6×7=42 both really are correct), not a routing bug where multiply silently called add. Checked directly with different numbers before trusting the transcript: add(10, 3) gives 13, multiply(10, 3) gives 30 — confirming each request really did reach the calculator's own distinct handler for that specific action, not just "some handler that happened to return the right-looking number once."

What this surfaced

One real, previously-unknown bug came directly out of building this, not from hunting for one: the worker's when handlers were first written conditionally, gated on which role the process was playing (if role == "greeter" then when greet do ... end end), which read naturally enough given every other conditional in the file. Every request came back completely empty. A minimal repro settled it in one line: if true then when x do print("fired") end end followed by emit("x", "") prints nothing at all — a when handler registered inside a conditional block simply never fires, silently, no error anywhere. The fix was to register every handler unconditionally at true top level instead, relying on the fact that a given process only ever actually gets asked for the one action its own role really offers — the other handlers exist in memory, harmlessly unused. Worth knowing for any future PatLang program: when is a top-level declaration, not a conditionally-executable statement, and nothing currently warns you if you write it the other way.

See also

Presence, Discovery, and Non-Blocking Spawn covers every piece of infrastructure this demo combines, in isolation, with its own worked examples and the design decisions behind each one. Runtime signals and the message queue are the two original building blocks underneath all of it.