Exemplar: A Build Daemon (Logic + GOAP + Signals + Events + Self-Timing)
A worked example combining five of PatLang's less-usual capabilities into one coherent system, whose native transcript is shown below โ see the goal-oriented demo for the logic/GOAP half in isolation, the signals demo for the signals half, and the journey page for the wider story this exemplar builds on.
A self-hosted build daemon showing five of PatLang's less-usual capabilities working together in one program. Logic (rule_add/solve) derives which of six synthetic build targets are stale from dep(target, dependency)/changed(target) facts, by real multi-hop backward chaining, not a hand-written graph walk. Goal-seeking (action_add/plan, GOAP) then produces the actual cost-optimal rebuild order โ and the cost of each action is that target's own real, historically measured build duration, not a guess: the planner is reasoning about genuine past performance. Self-timing (now_ms()) records every build's real duration to a small history file that survives between runs, seeding both the GOAP costs above and an upfront ETA on the very next run. Events (when/emit) drive a live dashboard as each target builds. Signals (self_hosting/lib/signals.patlang, unchanged) let a separate CLI invocation query live status (rebuilt count, ETA) or trigger rebuild/quit against the running daemon. The build graph is deliberately synthetic (fake targets, sleep_ms standing in for real compile work) so these five features stay legible rather than the demo being coupled to a slow, real build pipeline. Native transcript only, no "run in browser" button: needs real TCP sockets, a real persisted file, and real separate OS processes. The transcript below simulates the primary/secondary roles as two real OS threads within one process, for the same build-time-capture reason the signals demo above does โ see self_hosting/examples/build_daemon_demo.patlang for the genuine two-separate-process form you'd actually run yourself.
PatLang source
# Runtime signals for PatLang programs: a running process can receive a
# named signal + optional payload from OUTSIDE itself -- a second CLI
# invocation ("server.exe --quit" sent to an already-running server, not a
# second server starting up), or a local network call. Delivery reuses
# tcp_connect/tcp_read/tcp_write/tcp_close exactly as they already existed,
# and dispatch reuses the existing when/emit event mechanism (see
# self_hosting/examples/event_loop_server.patlang) -- a signal just becomes
# an emit() call, so any `when <name> do ... end` handler already in the
# receiving program fires normally, no new handler concept needed.
#
# The trick this relies on: tcp_listen binds 127.0.0.1 only (hosts.rs's
# host_tcp_listen -- not configurable, not a new restriction added here),
# and a second bind to the SAME port simply fails. That failure IS the "is
# a primary already running" detector, for free -- no separate lockfile/
# PID-file mechanism needed (PatLang has none today; this doesn't add one).
# The one genuinely new host function this needed: tcp_try_listen(port) ->
# port_id or -1 on AddrInUse specifically, rather than tcp_listen's fatal-
# on-any-bind-failure behavior -- PatLang has no try/catch, so there was no
# way to gracefully ask "is something already listening" without it. See
# hosts.rs's host_tcp_try_listen for the full rationale.
#
# Usage shape (see self_hosting/examples/signals_demo.patlang for a full
# worked example, including a request/response "status" signal):
# let port_id = signal_claim(9401)
# if port_id >= 0 then
# # primary: hold the port, poll it inside your own event loop
# when quit do set_var("should_stop", "1") end
# when status do signal_reply("uptime: " + get("__vars", "uptime")) end
# while running do
# ... your own work ...
# signal_poll(port_id, 20) # cheap, non-blocking; fires when/emit
# end
# else
# # secondary: someone else already owns this port
# signal_send(9401, "quit", "") # fire-and-forget
# let reply = signal_query(9401, "status", "") # request/response
# print(reply)
# end
#
# Deliberately minimal wire format (one line: "name\npayload", a single
# write + a single read, no length-prefix framing) -- matches this
# codebase's existing pragmatic style for small local control messages
# (see event_loop_server.patlang's own plain HTTP-ish read/write), not
# meant for large payloads. Localhost-only by construction (see above), so
# no separate auth story yet -- a real network-facing signal listener would
# need one before ever binding beyond 127.0.0.1; out of scope here, same as
# this feature's own backlog note said from the start.
# signal_claim(port) -> port_id (>= 0) if this process is now the primary
# holder of `port` for signal delivery, or -1 if another process already
# holds it (the caller should treat that as "an instance is already
# running" and typically call signal_send instead). Uses tcp_try_listen,
# NOT plain tcp_listen -- PatLang has no try/catch, so tcp_listen's bind
# failure is a FATAL error (would abort the whole program), not something
# this function could catch and turn into a graceful -1. tcp_try_listen
# was added specifically for this: -1 only for "already in use", any other
# bind failure (invalid port, permission denied) still aborts, same as
# plain tcp_listen would.
make a function called signal_claim takes port returns port_id
return tcp_try_listen(port)
end
# signal_poll(port_id, timeout_ms) -> the signal name received this call,
# or "" if none arrived within timeout_ms. On receipt, also calls
# emit(name, payload) so any `when name do ... end` handler in the calling
# program fires immediately -- callers that only need "did something
# arrive" can ignore the return value and rely purely on their own `when`
# handlers, matching event_loop_server.patlang's existing emit-driven style.
#
# Query-style signals (e.g. "status") get a reply channel: any `when`
# handler can call signal_reply(text) before returning, and that text is
# written back on the SAME connection before it's closed -- the connection
# is deliberately kept open across the emit() call rather than closed
# immediately after reading, specifically so a handler has something to
# reply on. Fire-and-forget signals (e.g. "quit") simply never call
# signal_reply, so an empty string is written back and the sender (using
# plain signal_send, which never reads a reply) never notices either way.
make a function called signal_poll takes port_id, timeout_ms returns name
let conn = tcp_accept_timeout(port_id, timeout_ms)
if conn < 0 then
return ""
end
let msg = tcp_read(conn)
let nl = find_substr_signals(msg, chr(10))
let sig_name = msg
let payload = ""
if nl >= 0 then
let sig_name = substr(msg, 0, nl)
let payload = substr(msg, nl + 1, msg.length - nl - 1)
end
set_var("__signal_reply", "")
emit(sig_name, payload)
tcp_write(conn, get("__vars", "__signal_reply"))
tcp_close(conn)
return sig_name
end
# signal_reply(text) -- call from inside a `when <name> do ... end` handler
# to send `text` back to whoever sent that signal via signal_query. Has no
# effect (silently ignored) if the signal was delivered via signal_send
# instead (fire-and-forget senders never read a reply anyway) or if this is
# called outside of a signal-triggered handler at all.
make a function called signal_reply takes text returns done
set_var("__signal_reply", text)
return true
end
# signal_send(port, name, payload) -> true once delivered, discarding any
# reply. Use this for fire-and-forget signals (e.g. "quit") where you don't
# need a response. Only call this after signal_claim(port) has already
# told you a primary holds the port (the intended usage shape above) --
# PatLang has no try/catch, so unlike signal_claim (which checks
# tcp_listen's own -1-on-failure return), tcp_connect raises a FATAL
# host-function error if nothing is listening, not a value this function
# could catch and turn into a graceful `false`. In practice this only
# matters for the narrow race where the primary exits between your
# signal_claim failing and this call running -- if that happens the caller
# sees a clear "tcp_connect: ... connection refused" error and the process
# aborts, which is an honest failure mode for a fire-and-forget CLI
# signal, not a silent no-op.
make a function called signal_send takes port, name, payload returns delivered
signal_query(port, name, payload)
return true
end
# signal_query(port, name, payload) -> whatever the primary's `when <name>
# do ... end` handler passed to signal_reply(...), or "" if it didn't call
# signal_reply at all. Use this for request/response signals (e.g.
# "status"). Same fatal-on-nothing-listening caveat as signal_send (no
# try/catch to catch tcp_connect's error gracefully).
make a function called signal_query takes port, name, payload returns reply
let conn = tcp_connect("127.0.0.1", port)
tcp_write(conn, name + chr(10) + payload)
let reply = tcp_read(conn)
tcp_close(conn)
return reply
end
# Tiny local substring search -- self-contained rather than depending on
# whichever example/tool happens to define one, since this is a shared
# library other programs `include`.
make a function called find_substr_signals takes hay, needle returns idx
if needle.length == 0 then
return 0
end
let i = 0
while i <= (hay.length - needle.length) do
if substr(hay, i, needle.length) == needle then
return i
end
let i = i + 1
end
return -1
end
# Self-hosted mirror of rust-runtime/src/preprocess.rs's expand_includes:
# expands `include "relative/path.patlang"` lines by splicing the
# referenced file's contents in place, resolving paths relative to the
# including file's own directory, recursively.
#
# Why this exists as PatLang, not just Rust: `expand_includes` was
# previously a NATIVE-ONLY preprocessing step (main.rs, run before the
# frontend ever sees the source) -- patc1.exe's own self-hosted lexer/
# parser never learned to do this, so any .patlang file using `include`
# could only be compiled via the native pat.exe frontend (`--ir-run`/
# `--patc`), never handed directly to patc1.exe, which is why every
# multi-file portfolio demo in build_portfolio.patlang manually
# concatenates dependency files (read_file(lexer) + chr(10) + ...) instead
# of using `include`. This closes that gap so `include` works identically
# everywhere -- interpreted, natively compiled, and self-hosted-compiled --
# matching this session's usual bar of "verified across all three paths."
#
# Kept as its own small library (not folded directly into patc1_main.patlang)
# so any self-hosted driver can `include "lib/includes.patlang"` and use it.
# str_trim(s) -> s with leading/trailing space/tab/\r stripped.
make a function called str_trim takes s returns trimmed
let n = s.length
let start = 0
while (start < n) and is_ws_char(char_code(s, start)) do
let start = start + 1
end
let end = n
while (end > start) and is_ws_char(char_code(s, end - 1)) do
let end = end - 1
end
return substr(s, start, end - start)
end
make a function called is_ws_char takes code returns is_ws
return (code == 32) or (code == 9) or (code == 13)
end
# str_starts_with(s, prefix) -> bool
make a function called str_starts_with takes s, prefix returns matches
if prefix.length > s.length then
return false
end
return substr(s, 0, prefix.length) == prefix
end
# split_lines(s) -> list of lines, split on \n (a trailing \r on each line,
# from CRLF source files, is stripped too).
make a function called split_lines takes s returns lines
let out = []
let n = s.length
let start = 0
let i = 0
while i < n do
if char_code(s, i) == 10 then
let raw = substr(s, start, i - start)
let out = list_push(out, strip_trailing_cr(raw))
let start = i + 1
end
let i = i + 1
end
if start < n then
let out = list_push(out, strip_trailing_cr(substr(s, start, n - start)))
end
return out
end
make a function called strip_trailing_cr takes line returns stripped
let n = line.length
if (n > 0) and (char_code(line, n - 1) == 13) then
return substr(line, 0, n - 1)
end
return line
end
# path_dirname(path) -> everything before the last '/' or '\', or "." if
# the path has no directory component. Handles both separators since
# build_portfolio.patlang and friends run on Windows but write forward
# slashes in string literals.
make a function called path_dirname takes path returns dir
let n = path.length
let i = n - 1
let last_sep = -1
while i >= 0 do
let c = char_code(path, i)
if (c == 47) or (c == 92) then
let last_sep = i
let i = -1
else
let i = i - 1
end
end
if last_sep < 0 then
return "."
end
return substr(path, 0, last_sep)
end
# path_join(base, rel) -> base + "/" + rel, tolerating a trailing slash on
# base and an empty base (meaning "current directory"). If `rel` is itself
# absolute (leading '/'/'\', or a Windows drive letter like "C:"), it's
# returned unchanged, ignoring base -- matches Rust's PathBuf::join, which
# preprocess.rs's native expand_includes relies on for the same case.
make a function called path_join takes base, rel returns joined
if is_absolute_path(rel) then
return rel
end
if (base == "") or (base == ".") then
return rel
end
let n = base.length
if (n > 0) and ((char_code(base, n - 1) == 47) or (char_code(base, n - 1) == 92)) then
return base + rel
end
return base + "/" + rel
end
make a function called is_absolute_path takes p returns is_abs
if p.length == 0 then
return false
end
let c0 = char_code(p, 0)
if (c0 == 47) or (c0 == 92) then
return true
end
if (p.length >= 2) and (char_code(p, 1) == 58) then
return true
end
return false
end
# expand_includes(source, base_dir) -> source with every `include "path"`
# line recursively replaced by that file's own (recursively expanded)
# contents, paths resolved relative to base_dir (the including file's own
# directory) at each level, exactly matching preprocess.rs's semantics.
#
# The depth cap (16, matching preprocess.rs's MAX_DEPTH) is inlined as a
# literal below rather than a top-level `let` constant referenced from
# inside expand_includes_at_depth -- patc1.exe was found, while building
# this, to NOT make top-level `let` constants visible inside function
# bodies at all (confirmed via a minimal repro: the value silently reads
# as empty/unset, not an error) even though both --ir-run and native
# --patc handle this correctly. That's a real, previously-unknown
# self-hosted-compiler bug, logged separately in the backlog for its own
# dedicated fix -- this file just avoids relying on the broken behavior.
make a function called expand_includes takes source, base_dir returns expanded
return expand_includes_at_depth(source, base_dir, 0)
end
make a function called expand_includes_at_depth takes source, base_dir, depth returns expanded
if depth > 16 then
print("include: nesting deeper than 16 levels (cycle?)")
return source
end
let lines = split_lines(source)
let out = sb_new()
let i = 0
let n = to_num(list_len(lines))
while i < n do
let line = lines[i]
let t = str_trim(line)
if str_starts_with(t, "include ") and (str_starts_with(t, "#") == false) then
let rel = str_trim(substr(t, 8, t.length - 8))
let rel = strip_quotes(rel)
let path = path_join(base_dir, rel)
let inner = read_file(path)
let inner_base = path_dirname(path)
sb_push(out, expand_includes_at_depth(inner, inner_base, depth + 1))
sb_push(out, chr(10))
else
sb_push(out, line)
sb_push(out, chr(10))
end
let i = i + 1
end
return sb_str(out)
end
# strip_quotes("\"path\"") -> "path" -- include lines are always written
# with double-quoted paths, same as the native preprocessor expects.
make a function called strip_quotes takes s returns unquoted
let n = s.length
if (n >= 2) and (char_code(s, 0) == 34) and (char_code(s, n - 1) == 34) then
return substr(s, 1, n - 2)
end
return s
end
# A self-hosted build daemon -- the exemplar system tying together five of
# PatLang's less-usual capabilities in one coherent program:
#
# - LOGIC (rule_add/solve): "which targets are stale" is derived from
# dep(target, dependency) and changed(target) facts via real backward
# chaining, not a hand-written graph walk.
# - GOAL-SEEKING (action_add/plan, GOAP): "what order should I build in"
# is a real cost-optimal forward search over build actions, where each
# action's cost is that target's own PERSISTED HISTORICAL DURATION --
# the planner is reasoning about real measured time, not a guess.
# - SELF-TIMING (now_ms()) + PERSISTENCE: every build records its own
# duration to a small history file, read back on the next run to seed
# both the GOAP costs above and an upfront ETA.
# - EVENTS (when/emit): job_started/job_finished drive a live dashboard,
# reusing the exact pattern build_portfolio.patlang's own precompile
# pass uses to log concurrent progress.
# - SIGNALS (self_hosting/lib/signals.patlang): a running daemon answers
# `status` (live dashboard state + ETA) and `rebuild`/`quit` from a
# separate CLI invocation, without any new IPC mechanism of its own.
#
# The build graph itself is deliberately synthetic -- a handful of fake
# targets "compiled" via sleep_ms standing in for real work -- rather than
# wired into the real (and, as of this session, `patc1.exe`-performance-
# anomaly-affected) build_portfolio.patlang pipeline. That keeps this
# demo's runtime short and its five showcased features legible, which is
# the actual point of an exemplar: see self_hosting/examples/
# build_daemon_demo.patlang for the real, multi-process, run-it-yourself
# form, and self_hosting/examples/goal_oriented_build_demo.patlang for the
# original (non-daemon, non-timed) version of the logic/GOAP half alone.
#
# NOTE on style: every "constant" below (the target graph, the history
# file path) is a FUNCTION returning a freshly-built literal, not a
# top-level `let` read from inside other functions. That's not just
# habit -- patc1.exe (the self-hosted compiler) was found this session to
# make top-level `let` constants silently invisible inside ANY function
# body when self-hosted-compiled (reads as empty, not an error; see
# [[patlang-backlog]] item 10), so this library avoids the pattern
# everywhere rather than risk it working under --ir-run/--patc and
# silently breaking under patc1.exe.
# ---- the build graph: targets and their dependency edges ----
# A plain list of [target, [deps...]] pairs, rebuilt fresh on every call
# (six tiny literal entries -- the cost of reconstructing it is
# irrelevant) so both the fact-registration code below and the dashboard's
# "N targets total" count can iterate it without depending on a top-level
# constant.
make a function called bd_targets returns targets
return [
["parse", []],
["typecheck", ["parse"]],
["compile_core", ["typecheck"]],
["compile_stdlib", ["typecheck"]],
["link", ["compile_core", "compile_stdlib"]],
["docs", ["parse"]]
]
end
make a function called bd_history_file returns path
return "portfolio/build/build_daemon_history.txt"
end
# Base "compile time" per target in ms, used only the very first time a
# target has no recorded history yet -- after that, real measured
# durations take over as the GOAP cost.
make a function called bd_default_cost takes target returns ms
if target == "parse" then
return 180
end
if target == "typecheck" then
return 260
end
if target == "compile_core" then
return 340
end
if target == "compile_stdlib" then
return 300
end
if target == "link" then
return 420
end
return 150
end
# ---- timing history: target:duration_ms, one per line ----
make a function called bd_load_history returns pairs
let out = []
let hfile = bd_history_file()
if file_exists(hfile) == "1" then
let raw = read_file(hfile)
let lines = split_lines(raw)
let i = 0
let n = to_num(list_len(lines))
while i < n do
let line = str_trim(lines[i])
if line.length > 0 then
let colon = find_substr_signals(line, ":")
if colon >= 0 then
let tgt = substr(line, 0, colon)
let ms = to_num(substr(line, colon + 1, line.length - colon - 1))
let out = list_push(out, [tgt, ms])
end
end
let i = i + 1
end
end
return out
end
make a function called bd_history_lookup takes history, target returns ms
let i = 0
let n = to_num(list_len(history))
while i < n do
let pair = history[i]
if pair[0] == target then
return pair[1]
end
let i = i + 1
end
return -1
end
make a function called bd_save_history takes history returns done
let b = sb_new()
let i = 0
let n = to_num(list_len(history))
while i < n do
let pair = history[i]
sb_push(b, pair[0] + ":" + pair[1] + chr(10))
let i = i + 1
end
write_file(bd_history_file(), sb_str(b))
return true
end
make a function called bd_history_record takes history, target, ms returns updated
let out = []
let found = false
let i = 0
let n = to_num(list_len(history))
while i < n do
let pair = history[i]
if pair[0] == target then
let out = list_push(out, [target, ms])
let found = true
else
let out = list_push(out, pair)
end
let i = i + 1
end
if found == false then
let out = list_push(out, [target, ms])
end
return out
end
# ---- LOGIC: register the dependency graph + a per-run "changed" subset ----
# rule_add has no retract -- a fact registered in one cycle stays in the
# RULES store forever within this process. That's harmless for the static
# "dep" edges (registered once, below), but "changed"/"stale"/"built" are
# genuinely per-cycle state: a long-running daemon calls bd_run_cycle
# repeatedly in the SAME process (on every "rebuild" signal), and without
# something to distinguish cycles, a "built(X)" fact from cycle 1 would
# still satisfy cycle 2's plan() goal even for a target cycle 2 just
# marked changed -- GOAP would see the goal as already met from stale
# leftover state and silently plan nothing. Fixed by suffixing these three
# predicate names with the current cycle number, so each cycle gets a
# fresh, disjoint set of facts; "dep" stays untagged since the dependency
# graph itself never changes between cycles.
make a function called bd_cycle_tag returns tag
let n = get("__vars", "bd_cycle_num")
return "" + n
end
make a function called bd_register_graph returns done
if get("__vars", "bd_graph_registered") == "1" then
return true
end
let targets = bd_targets()
let i = 0
let n = to_num(list_len(targets))
while i < n do
let entry = targets[i]
let target = entry[0]
let deps = entry[1]
let j = 0
let dn = to_num(list_len(deps))
while j < dn do
rule_add("dep", [target, deps[j]], [])
let j = j + 1
end
let i = i + 1
end
set_var("bd_graph_registered", "1")
return true
end
make a function called bd_register_stale_rule takes tag returns done
rule_add("stale" + tag, ["X"], [["changed" + tag, ["X"]]])
rule_add("stale" + tag, ["X"], [["dep", ["X", "Y"]], ["stale" + tag, ["Y"]]])
return true
end
# A target is "changed" this run if now_ms() (used only for its low bits,
# a cheap source of per-run variance) picks it out -- different targets go
# stale on different runs, like a real build where different files get
# edited each time. Deliberately picks exactly ONE target rather than
# always including "parse": every other target transitively depends on
# "parse" in this graph, so if "parse" were always marked changed, staleness
# would cascade to the whole graph on every single run, and the dashboard
# would never show the incremental "most targets already up to date" case
# this demo exists to illustrate.
make a function called bd_mark_changed_this_run takes history, tag returns done
let targets = bd_targets()
let pick = to_num(now_ms()) % to_num(list_len(targets))
let entry = targets[pick]
rule_add("changed" + tag, [entry[0]], [])
# A target with no recorded history has never actually been built, no
# matter what the dependency-derived staleness check would otherwise
# conclude -- "no verified prior output" counts as stale by definition,
# the same way a real build system treats a missing cache entry. Without
# this, a target that happens not to be the pseudo-random pick above
# (and isn't downstream of it) would be wrongly seeded as "already
# built" on the very first run, despite never having built at all.
let i = 0
let n = to_num(list_len(targets))
while i < n do
let target = targets[i][0]
if bd_history_lookup(history, target) < 0 then
rule_add("changed" + tag, [target], [])
end
let i = i + 1
end
return true
end
# ---- one build cycle: derive staleness, plan, execute, persist timing ----
# Returns the number of targets actually rebuilt.
make a function called bd_run_cycle returns rebuilt_count
set_var("bd_cycle_num", get("__vars", "bd_cycle_num") + 1)
let tag = bd_cycle_tag()
bd_register_graph()
bd_register_stale_rule(tag)
let targets = bd_targets()
let history = bd_load_history()
bd_mark_changed_this_run(history, tag)
# Seed "already built" facts for everything NOT stale, so GOAP's search
# doesn't need to (re)build what's already up to date -- exactly the
# same "world state = currently-registered ground facts" mechanism
# goal_oriented_build_demo.patlang uses. Uses this cycle's tagged
# "built"/"stale" predicates throughout, not the bare names, so a fact
# left over from an earlier cycle in this same long-running process can
# never be mistaken for this cycle's state (see bd_cycle_tag's comment).
let i = 0
let n = to_num(list_len(targets))
while i < n do
let target = targets[i][0]
let sols = solve("stale" + tag, [target])
if to_num(list_len(sols)) == 0 then
rule_add("built" + tag, [target], [])
end
let i = i + 1
end
# One GOAP action per target: preconditions are its deps being built,
# effect is itself being built, cost is its real historical duration
# (or the one-time default estimate if this is the first run).
let i = 0
while i < n do
let entry = targets[i]
let target = entry[0]
let deps = entry[1]
let preconds = []
let j = 0
let dn = to_num(list_len(deps))
while j < dn do
let preconds = list_push(preconds, ["built" + tag, [deps[j]]])
let j = j + 1
end
let cost = bd_history_lookup(history, target)
if cost < 0 then
let cost = bd_default_cost(target)
end
action_add(target, preconds, [["built" + tag, [target]]], [], cost)
let i = i + 1
end
let goal_facts = []
let i = 0
while i < n do
let goal_facts = list_push(goal_facts, ["built" + tag, [targets[i][0]]])
let i = i + 1
end
let build_plan = plan(goal_facts)
let eta_total = 0
let i = 0
let plan_len = to_num(list_len(build_plan))
while i < plan_len do
let target = build_plan[i]
let est = bd_history_lookup(history, target)
if est < 0 then
let est = bd_default_cost(target)
end
let eta_total = eta_total + est
let i = i + 1
end
set_var("bd_plan_len", plan_len)
set_var("bd_eta_total_ms", eta_total)
set_var("bd_done_count", 0)
let i = 0
while i < plan_len do
let target = build_plan[i]
let est = bd_history_lookup(history, target)
if est < 0 then
let est = bd_default_cost(target)
end
emit("bd_job_started", target + ":" + est)
let t0 = now_ms()
sleep_ms(est)
let actual = now_ms() - t0
rule_add("built" + tag, [target], [])
let history = bd_history_record(history, target, actual)
set_var("bd_done_count", get("__vars", "bd_done_count") + 1)
emit("bd_job_finished", target + ":" + actual)
let i = i + 1
end
bd_save_history(history)
return plan_len
end
# Build-transcript variant of build_daemon_demo.patlang: the real,
# intended usage is two separate OS PROCESSES (see build_daemon_demo.patlang
# and its own header comment) -- but a build script capturing a native
# transcript via exec_capture can't demonstrate that directly, since
# exec_capture blocks until its child exits, and the daemon only exits once
# it receives a quit signal from somewhere else. So this variant simulates
# the primary/secondary roles as two real OS threads (parallel_map) within
# ONE process instead of two separate ones -- same build_daemon.patlang
# functions, same TCP loopback signal delivery, same emit()-driven
# dashboard, just both ends visible in a single captured transcript.
#
# Port and target literals below are inlined rather than read from a
# top-level `let` constant referenced inside run_primary/run_secondary --
# functions invoked via parallel_map were found this session to not see
# top-level `let`s at all (each worker's fresh interpreter jumps straight
# into the target function, skipping the top-level statements that would
# normally set such a constant on the main thread); see
# self_hosting/examples/signals_demo_transcript.patlang for the first
# demo that hit this and self_hosting/lib/build_daemon.patlang's own
# header comment for the related (broader) patc1.exe-specific version.
when bd_job_started do
let colon = find_substr_signals(event_data, ":")
let target = substr(event_data, 0, colon)
let est = substr(event_data, colon + 1, event_data.length - colon - 1)
let done = get("__vars", "bd_done_count")
let total = get("__vars", "bd_plan_len")
print("PRIMARY: [" + (done + 1) + "/" + total + "] " + target + ": building (est. " + est + "ms)")
end
when bd_job_finished do
let colon = find_substr_signals(event_data, ":")
let target = substr(event_data, 0, colon)
let actual = substr(event_data, colon + 1, event_data.length - colon - 1)
let done = get("__vars", "bd_done_count")
let total = get("__vars", "bd_plan_len")
print("PRIMARY: [" + done + "/" + total + "] " + target + ": done (" + actual + "ms actual)")
end
when status do
let total = get("__vars", "bd_plan_len")
let done = get("__vars", "bd_done_count")
let eta = get("__vars", "bd_eta_total_ms")
signal_reply("build daemon: last cycle rebuilt " + done + "/" + total + " target(s), estimated " + eta + "ms total")
end
when rebuild do
print("PRIMARY: rebuild requested")
set_var("bd_rebuild_requested", "1")
end
when quit do
print("PRIMARY: quit requested, shutting down")
set_var("bd_should_stop", "1")
end
make a function called run_primary takes unused returns done
let port_id = signal_claim(9411)
print("PRIMARY: holding port 9411, running initial build cycle")
bd_run_cycle()
print("PRIMARY: initial cycle done, watching for signals")
set_var("bd_should_stop", "0")
set_var("bd_rebuild_requested", "0")
while get("__vars", "bd_should_stop") == "0" do
signal_poll(port_id, 50)
if get("__vars", "bd_rebuild_requested") == "1" then
set_var("bd_rebuild_requested", "0")
bd_run_cycle()
end
end
print("PRIMARY: exiting")
return true
end
make a function called run_secondary takes unused returns done
# The primary's initial cycle (6 fresh targets, default costs summing to
# ~1650ms worst case) needs to actually finish and reach its signal-
# polling loop before there's anything meaningful to query -- a signal
# sent earlier would just sit queued in the OS backlog, unprocessed,
# until the primary gets there anyway. 1900ms comfortably clears that.
sleep_ms(1900)
print("SECONDARY: querying status")
let reply1 = signal_query(9411, "status", "")
print("SECONDARY: " + reply1)
sleep_ms(100)
print("SECONDARY: requesting rebuild")
signal_send(9411, "rebuild", "")
sleep_ms(200)
print("SECONDARY: querying status again")
let reply2 = signal_query(9411, "status", "")
print("SECONDARY: " + reply2)
sleep_ms(100)
print("SECONDARY: sending quit")
signal_send(9411, "quit", "")
return true
end
make a function called run_thread takes kind returns done
if kind == "primary" then
return run_primary(0)
else
return run_secondary(0)
end
end
print("before parallel_map")
parallel_map(["primary", "secondary"], "run_thread")
print("DONE")
Native run on the build machine:
before parallel_map PRIMARY: holding port 9411, running initial build cycle PRIMARY: [1/6] parse: building (est. 180ms) PRIMARY: [1/6] parse: done (180ms actual) PRIMARY: [2/6] docs: building (est. 150ms) PRIMARY: [2/6] docs: done (151ms actual) PRIMARY: [3/6] typecheck: building (est. 260ms) PRIMARY: [3/6] typecheck: done (260ms actual) PRIMARY: [4/6] compile_stdlib: building (est. 300ms) PRIMARY: [4/6] compile_stdlib: done (301ms actual) PRIMARY: [5/6] compile_core: building (est. 340ms) PRIMARY: [5/6] compile_core: done (341ms actual) PRIMARY: [6/6] link: building (est. 420ms) PRIMARY: [6/6] link: done (420ms actual) PRIMARY: initial cycle done, watching for signals SECONDARY: querying status SECONDARY: build daemon: last cycle rebuilt 6/6 target(s), estimated 1650ms total SECONDARY: requesting rebuild PRIMARY: rebuild requested PRIMARY: [1/4] typecheck: building (est. 260ms) SECONDARY: querying status again PRIMARY: [1/4] typecheck: done (260ms actual) PRIMARY: [2/4] compile_stdlib: building (est. 301ms) PRIMARY: [2/4] compile_stdlib: done (302ms actual) PRIMARY: [3/4] compile_core: building (est. 341ms) PRIMARY: [3/4] compile_core: done (341ms actual) PRIMARY: [4/4] link: building (est. 420ms) PRIMARY: [4/4] link: done (421ms actual) SECONDARY: build daemon: last cycle rebuilt 4/4 target(s), estimated 1322ms total SECONDARY: sending quit PRIMARY: quit requested, shutting down PRIMARY: exiting DONE