Status: current high-level architecture reference. Subsystem documents in the documentation index own detailed contracts and rollout history. Treat anything labelled "TBD" here as an open question, not a planned commitment.
╔══════════════════════════════════════════════════════════════════════╗
║ CYNIC ENGINE ║
║ strict-only ECMAScript engine, built from scratch ║
╚══════════════════════════════════════════════════════════════════════╝
EXECUTION PIPELINE
──────────────────
source bytes
│
▼
┌─────────┐ Diagnostic records (severity + code + span),
│ Lexer │ accumulated — not panics
└────┬────┘
▼
┌─────────┐ arena-per-parse, no global state,
│ Parser │ every AST node carries a source span
└────┬────┘
▼
┌─────────┐
│ AST │
└────┬────┘
▼
┌──────────────────┐
│ Bytecode Compiler│ register file + accumulator (Ignition/Hermes)
└────┬─────────────┘ bytecode = source of truth for IR
▼
┌──────────────────┐
│ Lantern (T0) │ ◄── ships today
└────┬─────────────┘
│ hot
▼
┌──────────────────┐
│ Bistromath (T1) │ ◄── default-on, AArch64 + x86_64 (M5)
└────┬─────────────┘
│ hotter
▼
┌──────────────────┐
│ Ohaimark (T2) │ ◄── default-on checked/IC AArch64 + x86_64 (M6)
└──────────────────┘
data flows T0→T1→T2; deopt can fall all the way back to Lantern (T0)
ENGINE STATE (owned by an `Engine` struct, explicit allocator threading)
────────────
┌────────────────────────── Realm (realm.zig) ───────────────────────┐
│ │
│ ┌──────────┐ ┌────────────┐ ┌───────────────┐ ┌──────────────┐ │
│ │ Heap │ │ Intrinsics │ │ Microtask │ │ Module cache │ │
│ │ + Metla │ │ table │ │ queue │ │ │ │
│ └──────────┘ └────────────┘ └───────────────┘ └──────────────┘ │
│ │
│ ┌─────────────── GlobalBindings (GlobalEnvironmentRecord) ───────┐ │
│ │ object env : global object — var/function + host bindings │ │
│ │ declarative : let/const/class (NOT on globalThis) │ │
│ │ [[VarNames]] : §9.1.1.4 set │ │
│ └────────────────────────────────────────────────────────────────┘│
│ │
│ Realm.output : ArrayListUnmanaged(u8) — buffers print/console.log │
└────────────────────────────────────────────────────────────────────┘
VALUE REPRESENTATION HEAP / METLA (GC)
──────────────────── ─────────────────
NaN-boxing, JSC encoding Metla: stop-the-world mark-sweep
64-bit Value word: over per-type free lists,
• doubles stored unboxed inline triggered on allocation pressure
• non-doubles: 16-bit tag in NaN per-type lists tracked:
payload + 48-bit ptr/immediate JSString JSFunction JSObject
tags: Int32 String Object Bool Environment JSGenerator
Null Undefined Hole JSSymbol JSBigInt
Object tag = JSFunction|JSObject, triggers: allocs_since_gc vs
discriminated by ptr bit 0 gc_threshold (16,384) OR
Hole (0xFFFF) = TDZ sentinel bytes_since_gc vs 16 MiB
→ reading raises ReferenceError roots: globals, intrinsics,
microtasks, modules, frames,
open HandleScopes
OBJECT MODEL & ENVIRONMENTS
───────────────────────────
Object: hashtable-backed props Environment records (one struct,
(+ proto slot reserved; flags per binding):
shapes/hidden classes = later) • Declarative — blocks, bodies
• Function — args/this/home
Every named binding is heap-allocated via JSFunction
in a declarative Environment; • Module — imports = live
closures capture enclosing env ptr aliases, TDZ-Hole-seeded
• Global — object+decl split
BUILT-IN LAYER (data ─vs─ API split)
──────────────
runtime/<X>.zig ──► heap-side JSX struct (fields, init/deinit)
runtime/builtins/<X>.zig ──► JS-visible API surface, `install(realm)`
runtime/intrinsics.zig ──► orchestrator + shared helpers; calls each
builtin's install() at startup
families: Object Array String Number Map/Set Promise TypedArray Date
BigInt Symbol Proxy Reflect RegExp JSON Math Error …
MODULE SYSTEM
─────────────
Source Text Module Record (§16) — static import/export, indirect live
aliases, dynamic import(), import.meta, top-level await.
No CommonJS. Loading is host-driven via Realm.module_loader callback.
Cynic also runs WebAssembly, through Sarcasm — a from-scratch
in-place wasm interpreter that is a parallel subsystem to the JS engine
above: its own decoder, validator (emitting an O(1) branch side-table),
and threaded dispatch loop in src/runtime/wasm/,
with the WebAssembly.* host API in runtime/builtins/webassembly.zig.
It is 100% conformant on the official spec testsuite and shares the Metla
heap (an externref is a pinned JS value), Realm fuel/interrupt policy, and
the live-byte ceiling. Its design has its own record in
wasm-engine.md.
Cynic does not implement sloppy mode. Every script and module is parsed as strict. Practical consequences:
let,static,interface,package,private,protected,public,implements,yieldare always reserved.evalandargumentscannot be assignment targets.withis a SyntaxError.- No legacy octal integer literals (
0755). - No legacy octal escape sequences in strings (
"\17"). - No HTML-like comments (
<!--,-->). deleteof a bare identifier is a SyntaxError.- Functions don't expose
arguments.callee/arguments.caller. - Annex B §B.3 grammar additions are not implemented.
One spec area where Cynic deliberately diverges from browsers:
regex grammar §B.1.4 (\1 outside a capturing group as octal, the
lower-bound-elided quantifier {,n}, and two more). Every shipping
browser engine relaxes these without /u or /v; Perlex — Cynic's
native regex engine — rejects all of them in every mode, since the
non-browser target has no reason to carry the Annex B leniency. See
AGENTS.md for the rationale.
This narrows the surface and sharpens the spec-conformance
target — Cynic is at ~89 % spec / ~96 % attempted on the
runtime-mode test262 sweep (see test262-results.md).
Internal functions track ECMA-262 abstract operation names where practical:
ParseScript, Evaluate, ToNumber, OrdinaryGet, etc. The mapping from a
test262 failure to the abstract operation responsible should be easy to find.
Every component takes a std.mem.Allocator. There are no globals. Long-lived
state (heap, parser arena, JIT code arena) is owned by an Engine struct that
the embedder constructs explicitly. This makes lifetimes explicit and makes
arena-per-compilation cheap.
Lexer and parser collect Diagnostic records (severity + code + span) instead
of aborting on the first error. Tests assert on exact diagnostic codes, which
makes test262's expected-error tests precise to wire up.
NaN-boxing, JSC encoding. Decided at later; see src/runtime/value.zig.
A Value is a 64-bit word. Doubles (the spec's default numeric type
per §6.1.6.1) are stored unboxed inline — every IEEE-754 finite double
is a valid encoding. Non-double values use the NaN payload: a 16-bit
tag in the high bits (Int32, String, Object, Bool, Null,
Undefined, Hole) plus the heap-pointer or immediate in the low 48
bits.
Cynic uses one Object tag for both JSFunction and JSObject.
Discrimination happens at bit 0 of the stored pointer: heap allocations
are 8-byte-aligned, so bit 0 is free. taggedFunction leaves it
clear; taggedObject sets it. This avoids depending on
compiler-determined struct field layout, which previously broke a
kind-discriminator-as-first-field design.
Hole (tag 0xFFFF) is a distinct sentinel for let / const
slots in TDZ; reading it raises ReferenceError.
The pointer-tagged-Smi alternative (V8 style) was considered and rejected: it exists to interop with V8's pointer compression, which is unrelated to a from-scratch interpreter like Lantern.
Metla is Cynic's non-moving generational mark-sweep collector over per-type
free lists, triggered on allocation pressure. Minor collections are
stop-the-world; major marking is incremental and final sweeping is lazy. See
src/runtime/heap.zig for the collector itself
(the Heap struct holds storage; the collect* functions are Metla);
the trigger and the realm-wide root walker live in
src/runtime/realm.zig and Lantern's dispatch
loop in src/runtime/lantern/interpreter.zig. The
operational details — root set, threshold, the HandleScope contract
for natives — are in docs/handbook/gc.md.
Heap tracks JSString, JSFunction, JSObject, Environment,
JSGenerator, JSSymbol, and JSBigInt on separate lists. Each
allocateX increments allocs_since_gc; large heap-owned payloads
(string bytes, ArrayBuffer slabs) additionally charge(n) the byte
counterpart bytes_since_gc. The dispatch loop checks either
counter — count against gc_threshold (default 16,384) or charged
bytes against gc_byte_threshold (default 16 MiB) — between opcodes
and runs Realm.collectGarbage when one crosses. Roots include the
realm's globals, intrinsics, microtask queue, modules, top-level
chunks, the active frames' registers + accumulator + this + env +
home_object, and any open handle scopes.
The heap also exposes always-on counters (bytes_alloc_total,
bytes_live_peak, gc_cycles_total, gc_time_ns_total) that the
test262 harness surfaces via --mem-summary, --top-alloc=<N>, and
the existing --gc-stats line.
Reference counting and a wholesale heap-layout rewrite were prototyped or measured as no-go paths; the retained design is documented in the GC handbook. Parallel or fully concurrent collection remains research-only.
Register file + accumulator, Ignition / Hermes style. See src/bytecode/op.zig.
Per-frame register file; an implicit accumulator threads through
binary and unary ops to keep Lantern's dispatch loop tight. One-byte
opcodes for the common case. Op.spec() is the authoritative schema
for operand layouts, control flow, and tier support. Finalization
selects narrow immediate / IC forms, relaxes relative branches to
i8/i16/i32, and remaps source, handler, and dense-switch side tables.
Load, store, and computed property sites use separate typed IC arrays
so their layouts and index spaces stay small. Bytecode is the source
of truth for IR — Bistromath (AArch64 + x86_64 on macOS/Linux,
default-on since 2026-06; --no-jit opts out; other targets interpret)
and Ohaimark (AArch64 + x86_64, default-on at natural thresholds;
--no-ohaimark isolates T1, while --no-jit disables both). Ohaimark's
feedback/SSA/specialization/representation/deopt-home front end, differential
evaluator, checked-int32/Number/property-IC AArch64 subset,
frame-reconstructing backedge safepoints, transactional chunk-owned code,
single-predecessor edge coalescing, one-word completion ABI, loop-header OSR,
and rooted compact call/construct/property handoff have landed. A qualified
x86_64 backend shares the recovery, feedback, and rooted-helper policy across
checked control, named/computed data ICs, lexical/closure/literal operations,
terminal replay, and call handoff while keeping separate compact-leaf and
general-CFG emitters. On either qualified ISA, T2 refusal resumes through
Bistromath when eligible and Lantern otherwise. Both tiers consume bytecode plus warmth/IC data,
not the AST. Ohaimark's accepted design and GC-pointer-free feedback boundary
are in ohaimark.md.
An opt-in instrumentation build (-Dbytecode-stats=true, then
cynic run --bytecode-stats) reports static encoding widths and
dynamic logical opcode/pair/trigram frequencies plus the number of
logical opcodes entered by a direct-threaded transfer. Normal builds
compile the dispatch counters out. The CFG/liveness pass uses the same
schema, models dense-switch N-way edges, and fails closed when an
implicit register effect is not understood. Accumulator forwarding is
a post-finalization rewrite: Star r; Ldar r loses the load only when
the finalized leader set proves no branch, switch, or handler can enter
between the pair.
A pure-stack design (QuickJS) was considered and rejected: higher dispatch counts, and worse fit for Bistromath, which wants a register file (Sparkplug, JSC Baseline both work that way).
The tier architecture itself — Bistromath, Ohaimark, Spasm (Sarcasm's wasm baseline tier), the codegen substrate all three share, and the JS↔wasm call-boundary fast path — is pinned in jit.md.
Plain hashtable-backed properties at later, with a prototype-pointer slot reserved for later. See src/runtime/object.zig.
Shapes / hidden classes are mandatory long-term — without them every
property access is a hashtable lookup, and every M5 inline-cache site
must be retrofitted. The intended design (Self / V8 lineage,
Chambers & Ungar) is Shape = (parent_shape, key, attrs, slot_index)
transition nodes with a per-parent transition cache and a
dictionary-mode fallback after a transition threshold. Landing this
is later.
Every named binding (var / let / const, function params,
function-name self-binding, imports) lives in a heap-allocated
declarative Environment; closures capture the enclosing env
pointer. See
src/runtime/environment.zig.
Four record shapes ship today, distinguishing flags on the binding rather than separate types:
- DeclarativeEnvironmentRecord — the default. Block scopes, function bodies, catch params.
- FunctionEnvironmentRecord — same
Environmentstruct; args,this, and[[HomeObject]]are plumbed throughJSFunctioninstead of the env itself. - ModuleEnvironmentRecord — same
Environment; imports carryis_import = trueand read indirectly through the source module's namespace (TDZ-Hole-seeded at instantiation per §15.2.1.16.4). Writes throw TypeError. - GlobalEnvironmentRecord — split into the object env
(the realm's global object, holds
var/functiondecls and host bindings) plus a declarative env (let/const/class, NOT mirrored onglobalThis) plus a[[VarNames]]set per §9.1.1.4. Top-level writes lower to one of three opcodes (sta_global_init,sta_global_fn_decl,sta_global) that route through the correct sub-record.
Named function expressions (let r = function G() {...}) get
a synthetic one-binding wrapper env between the function body
and the captured outer env, holding G as immutable per
§15.6.5.
The "register slots for non-captured bindings, env slots for captured ones, plus escape-analysis pre-pass" alternative was deferred when "everything on the heap" proved correct and fast enough; the escape-analysis pass is a later optimization.
See docs/handbook/environments.md for the per-opcode dispatch table, the §16.1.7 early-error pass, the const InitializeBinding / SetMutableBinding distinction via the TDZ-Hole sentinel, and the named-fn-expr-wrapper-env construction. Read it before touching binding declaration or resolution.
Cynic ships a sizable spec surface (Object, Array, String, Number,
Map, Set, Promise, TypedArray, Date, …). Each lives in its own
file under src/runtime/builtins/, exporting a pub fn install(realm)
that wires the constructor + prototype methods + statics. The
top-level intrinsics.install(realm) calls each in turn. The
current set of builtin files is discoverable from src/runtime/builtins/
directly; this document avoids enumerating them so it doesn't drift
on every add.
Layer split. The src/runtime/<X>.zig file holds the heap-side
JSX struct (fields, init / deinit, internal slot access). The
src/runtime/builtins/<X>.zig file holds the JS-visible API surface
(constructor body, prototype methods, install function). Five
names overlap (bigint, function, object, string, symbol):
| File | Responsibility |
|---|---|
runtime/<X>.zig |
Engine-internal data structure |
runtime/builtins/<X>.zig |
User-callable JS API |
builtins/X.zig imports runtime/X.zig for the JSX type; the
heap-side struct knows nothing about the JS API. intrinsics.zig
exposes shared helpers (installConstructor, coerceToNumber,
toPrimitive, getPropertyChain, throwTypeError, …) that every
builtin reaches for via a single import alias at the top of the
file.
Realm (src/runtime/realm.zig) owns
the heap, the intrinsics table, the microtask queue, the module
cache, and a GlobalBindings struct (the
GlobalEnvironmentRecord — see Environment records above for the
object / declarative / [[VarNames]] split). Host bindings
(print, console, etc.) are installed on the global object
via per-builtin install(realm) calls and looked up by the
lda_global opcode when an identifier reference doesn't
resolve in any user scope. Multiple realms are also the
foundation for the SES / Compartments direction, and they ship
today: per-realm intrinsics and globals, a RealmStack with each
function's [[Realm]] resolved at call time, and ShadowRealm
with per-realm teardown. Cross-realm calls resolve intrinsics
through the executing function's realm, not the dispatch realm.
The Compartment surface on top stays deferred — a TC39 Stage 1
proposal — see docs/multi-realm.md.
print and friends are wired as JSFunction instances with a
native_callback: ?NativeFn field (a native function pointer).
Lantern's Call op fast-paths native callees: it forms an args slice
over the register window and invokes the native function directly.
Realm.output buffers print / console.log output. The host (CLI
or test runner) flushes it after a script finishes. Buffering avoids
threading an I/O abstraction into the runtime, which would touch
every allocation site.
Cynic targets Source Text Module Record per §16. import /
export are first-class; CommonJS interop isn't shipped.
What's wired today:
- Static
import/export(including re-exports,export * from, and StringLiteral as ModuleExportName per §16.2.3.5 —export * as "ns" from "src",import { "y" as local }). - Indirect imports as live aliases per §8.1.1.5.5 CreateImportBinding — reads dereference through the source module's namespace; writes throw TypeError.
- TDZ for indirect imports: the source module's exported
let/const/class/export defaultslots are Hole-seeded at instantiation so the importer sees a ReferenceError before the source body has initialised them (§15.2.1.16.4 step 12).export const { x } = objdestructuring patterns are walked to every binding leaf, matching whatlet/constdeclarators publish. - Module Namespace exotic ([[Get]] §9.4.6.7) routes through
GetBindingValuewithstrict = true, surfacing the TDZ Hole as ReferenceError.[[HasProperty]]and[[OwnPropertyKeys]]do NOT — only[[Get]]honors TDZ.IsExtensible/SetPrototypeOfare brand-aware per §9.4.6.{1,3}, refusing extension and prototype change with the spec-mandatedfalse.@@toStringTag = "Module"is installed at brand-on-allocation time so cycles see the right tag while the namespace is still mid-evaluation. - Top-level
await(§16.2.1.5.1). Module bodies with TLA compile withchunk.is_async_module = true; the runtime routes them throughstartAsyncCallto produce an evaluation Promise. Async deps suspended at TLA land onModuleRecord.pending_async_deps; the compiler emits amodule_link_completeopcode after the importer's hoisted import block to drain microtasks (and propagate any dep rejection as an abrupt completion at the link boundary). Cynic doesn't yet model the full §16.2.1.5 [[PendingAsyncDependencies]] count or [[AsyncEvaluationOrder]] sort — sufficient for every TLA fixture in today's corpus. - Dynamic
import()per §13.3.10 — returns a Promise that rejects withSyntaxErroron instantiation failure (ambiguous indirect-export, circular re-export, etc.). For async deps the import() Promise drains microtasks until the dep's evaluation Promise settles, so callers see the post- TLA namespace rather than a partial mid-await view. import.meta.
Module loading is host-driven via Realm.module_loader — a
callback the embedder installs (the CLI resolves siblings on
disk; the test262 harness reads from vendor/test262/test/).
- Inline
testblocks per file for unit coverage, run via the project's test build target. See AGENTS.md for commands. - Custom test262 harness (
tools/test262.zig, ~3 k lines) scored against the Cynic-targeted scope: Annex B,intl402/,staging/,harness/, and the browser-era built-ins Cynic doesn't ship are dropped fromtotalentirely. The harness runs inruntime(parse + compile + execute) andparser(parse-only) modes, supports--filter+--only-failingfor fast iteration, and ships diagnostics flags (--top-rss,--top-slow,--top-alloc,--mem-summary,--leak-check,--max-rss) for the kind of leak / perf bisecting that comes up in shared-machinery work. Per-day score rows + bucket scoreboard live intest262-results.md. AGENTS.md "Build & test" covers the iteration cadence and the leak-check policy for new allocation paths; docs/handbook/agent-checks.md covers the regression-check protocol for changes that touch shared compiler / runtime machinery. - Differential testing against V8's
d8is still on the table for behavioural regressions where the spec is ambiguous (mostly handled today by direct reference to the spec text via thetc39MCP server in.mcp.json).