writeonce/docs/examples/log-watcher
shoney.arickathil 3c53d27347 fix: close per request, soak the daemon, kill five soak-found leaks (Tasks 5+6)
- Task 5: net.close on every path out of a serve iteration (400
  included) and the listener on stop; measured 4 -> 54 fds over 50
  requests before, 4 -> 4 over 200 after. The loop's comment claimed
  the iteration-end drop IS the close -- wrong twice (net.Conn is a
  scalar, and a drop would not close an fd); it now says what is true
- Task 6: LW_SOAK=<seconds> in the acceptance script -- each mode under
  load, resident+descriptor deltas against a WARMED baseline (warm-up
  includes load: cold-to-high-water is not growth), 256 KiB / zero
  tolerance; LW_ACCEPT_WOVM soaks another build
- the soak caught ~1.6 MiB/min of in-arena leaks ASan cannot see (the
  arena is one allocation to LeakSanitizer); an arena size-class
  census + pointer trace attributed five bugs:
  - jparse_string sized every decoded string at "rest of the input"
    and relabeled len after -- blocks filed on free lists their next
    allocation never reads (fs.read_all's mis-size, again); copy out
    exact, free at the taken size
  - `!=` never dropped fresh operands (headers["authorization"] !=
    "Bearer ${key}" leaked both sides per request); Ne now reaps as Eq
  - an Int interpolation segment is a fresh int_to_text, not a borrow;
    is_borrowed_value_t asks the segment's type
  - json.encode(Ctor{...}) had no owner -- record + both field copies
    leaked per tool call; its bespoke lowering now drops the argument
  - a discarded expression statement owns its result: `pop(lines);`
    leaked the popped element; reader builtins excluded
- after: arena live bytes flat per request on every handler; release
  soak 30 s per mode watch 0 / run 0 / mcp +20 KiB, descriptors flat;
  ASan build flat at 14600 KiB across 601686 requests in 90 s past its
  ~1200-request quarantine warm-up
- gates: oop-accept ALL CRITERIA MET, oop-e2e 71/0, woc-test 565/0,
  wovm-test green, log-watcher 7/0 (soak opt-in, fast path <1 min)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 00:32:49 +02:00
..
cron.wo docs: log-watcher program 2026-08-10 09:26:07 +02:00
logtail.wo feat: program mode (argv + exit code); reject + on Text; nil compares by word 2026-08-14 17:18:01 +02:00
main.wo docs: log-watcher program 2026-08-10 09:26:07 +02:00
mcp.wo fix: close per request, soak the daemon, kill five soak-found leaks (Tasks 5+6) 2026-08-15 00:32:49 +02:00
probes.wo docs: log-watcher program 2026-08-10 09:26:07 +02:00
README.md docs: log-watcher program 2026-08-10 09:26:07 +02:00
supervisor.wo feat: program mode (argv + exit code); reject + on Text; nil compares by word 2026-08-14 17:18:01 +02:00
watcher.wo docs: log-watcher program 2026-08-10 09:26:07 +02:00
wo.toml docs: log-watcher program 2026-08-10 09:26:07 +02:00

log-watcher — the systems-track sample workload

The Haxe original (~/projects/log-watcher, ~1,200 lines compiled to C++) ported file for file, per the approved systems-track design (Part 4). A single-binary systems daemon: log-tail watcher, cron.d supervisor, flock/pgrep probes, hand-rolled MCP-over-HTTP server, JSONL detection sink. Program mode (fn main, blocking legal, one shard) plus the five builtin stdlib modules — fs, proc, net, time, json — carry all of it; read each .wo next to its .hx sibling.

Status: design artifact — the spec's forcing function. The systems track is approved, pre-implementation. Today's woc (milestone 1) recovers the class/fn skeletons in these files (--dump-ast lists every Watcher method) but diagnoses the adopted surface as WO-E101: use, typedef, standalone union aliases (type CronResult = …), pub(read), switch, try. This sample exists to force that grammar (the blog/ecommerce/pricing precedent) and becomes the track's acceptance test: it compiles and detects a real silent death when the track ships.

The mapping

.wo file .hx sibling carries could not express
main.wo Main.hx subcommand dispatch; config decode into a typedef with ?fields —
logtail.wo LogTail.hx TailState record, bounded tail reads, rotation-by-inode, strict/relaxed classification, last_lines —
watcher.wo Watcher.hx the per-log state machine: ALERT/CLEAR rule, cron done()/result(), CronResult union —
cron.wo Cron.hx cron.d parse (aliases, redirect target, flock path), next-fire scan with the Vixie dom/dow OR quirk —
supervisor.wo Supervisor.hx the 250 ms event loop: rescan, collapse, pre-fire lock probe, completions, detections sink —
probes.wo Flock.hx, Pgrep.hx proc.run exit-code probes as static fns, safe-direction fallbacks —
mcp.wo Mcp.hx, Tools.hx typed request/response records; pure handle(req) -> resp kept socket-free; serve loop over net —

The spec's Part 4 table folds Supervisor.hx into the other files; supervisor.wo stays separate because the original is a separate file and the loop is the program. MiniLog.hx and its two tools (load_log_db/query_log_db) are deliberately absent: the spec's out-of-scope list assigns "sqlite-equivalent embedded SQL over RAM" to the DB engine — a future sample wires MiniLog's idea to select.

What the port deletes

Haxe Why it's gone
import sys.FileSystem / sys.io.File / sys.io.FileSeek one use fs; read_at takes the offset — no seek state, no open/close bookkeeping (RAII: handle drop = close)
LogTail.newState() record field defaults; construction is the brace literal
Util.hx (say = println + flush) print flushes on newline (spec Part 2)
loadConfig's Dynamic field-poking one json.decode(raw) as FileConfig — typed, ?fields, nil on mismatch
try … catch (e:Dynamic) probes try … catch (e) over the trap system; expected absence is ?T/nil instead
#if portable linker pragma woc build is a static single binary by doctrine
socket try c.close() catch dance connection is an owned value; scope end closes it
while(true) with no way out env.stopping() — SIGTERM/SIGINT land as a flag, no signal callbacks

What this sample forces (spec follow-ups)

The spec's five modules cover the I/O; writing real code forced these additions, in shrinking order of importance:

  1. time.local(ms) -> {year, month, day, hour, minute, dow} and time.iso(ms) -> Text — cron next-fire needs calendar decomposition in the host timezone; the detections sink needs an ISO stamp. The spec's time sketch (now/mono/sleep) cannot express cron.
  2. json.Value — an opaque, re-encodable JSON value. JSON-RPC echoes id back verbatim (number | string | null); no record type can hold it. Decode-target position only, like the as rule.
  3. fs.stat gains dir: Bool — cron.d scanning must skip subdirectories; {size, inode, mtime} cannot.
  4. Text builtins: len, substr(s, start, len), split, split_ws, trim, starts_with, ends_with, index_of, last_index_of, to_lower, byte_at, char_of, parse_int, "${…}" interpolation. No regex module exists — the original's five ERegs are hand-rolled scans (see classify_loose, is_env_line, find_log, sanitize).
  5. Collection builtins: push, pop (returns the removed element), shift, slice, reverse, sort, join; map index (returns ?V), has, remove, for k, v in m.

Deliberate divergences

Original Port Why
Float seconds everywhere Int milliseconds no Float scalar in the language; ms-as-Int matches the runtime (and Money's minor-units precedent)
config services entries: string or {path} object strings only typed decode; the object form was never used in the deployed config
tool schemas built per call one JSON const static data is static; the wire bytes are identical
JSON-RPC envelope via Json.stringify of a Dynamic typed sub-records json.encoded into a concatenated envelope typed json has no heterogeneous-object builder; id passes through as json.Value
list_logs mtime: seconds (float) ms (int) consistency with every other timestamp in the port

Memory model: why this sample has zero @gc and zero @table

The Haxe original transcompiles to C++ (bin/src/*.cpp, hxcpp target), which makes the contrast measurable — there, everything is @gc:

  1. Every object is GC-heap: new Watcher_obj behind hx::ObjectPtr, with HX_DEFINE_STACK_FRAME on every method so the collector can scan roots.
  2. Every typedef is a Dynamic hash object: TailState has no C++ struct — the hot poll path does this->st->__Field(HX_("lastLevel",…)), a hashed string lookup per field access, every 2 s, per watched file (LogTail.cpp has 34 Dynamic sites, Supervisor.cpp 91, Mcp.cpp 123).
  3. Allocation per poll: poll() returns a fresh anonymous GC object each call.

The port's ownership graph is a pure tree, so MVS covers all of it with owned values and @gc earns its keep nowhere:

Object Owner wo semantics
TailState its Watcher (field) owned value; the __Field hash lookup becomes a fixed-offset load
Watcher Supervisor.services or .active — never both owned in one container; remove() IS the destructor — the Haxe kill-self comment, now literal
CronWatch scheduled map owned; iteration mutates via borrow
PollResult, ParseResult, every record callee frame stack lifetime, DROP at scope end, zero heap
Tools, Mcp main → Mcp.tools owned chain

hxcpp pays GC on 100% of these objects; MVS pays on 0%. @gc would only enter if a shape changed — one Watcher aliased by two live registries at once, or a shared mutable cache aliased across requests (the OOP spec's PriceCache pattern). log-watcher has neither.

@table is also correctly absent: it configures storage for engine-bound classes, and program mode has no DB engine (sub-project 3). The C++ shows exactly where it lands later — MiniLog.cpp's in-memory sqlite, entries(id, path, seq, level, body, byteOffset) + idx_level, which is the future sample the spec names (wiring MiniLog's idea to select): @table(name: "entries", index: [path, seq]) on a LogEntry class turns load_log_db/query_log_db into plain insert/select. Same for the detections JSONL → a @table type with insert replacing open-append-close. Until the engine links, annotating anything here would claim storage that doesn't exist.

Try it (when the track ships)

woc build docs/examples/log-watcher            # single static binary
log-watcher watch /var/log/myapp.log 10 2      # ALERT/CLEAR on stdout
log-watcher run /etc/cron.d config.json        # supervisor
log-watcher mcp /etc/cron.d config.json        # MCP on 127.0.0.1:<port>

Acceptance (spec): compiles; the tail state machine, cron next-fire, and MCP handle pass fixtures ported from the Haxe test suite; watch detects an error-final quiet period against a growing tempfile, end to end.