Logos.Runtime (Logos v0.2.0)

Copy Markdown

Logos.Runtime -- the per-embedding-instance, :public ETS-backed namespace registry. Never a global singleton: every embedder of Logos calls Logos.Runtime.new/1 (or the Logos.new_runtime/0 convenience) to get its own isolated table, so multiple independent Logos instances can coexist in the same VM without seeing each other's namespaces or vars. An earlier version of this codebase instead kept a single bare, module-level, :named_table ETS table shared by the whole VM; this module replaced that global-singleton design.

Storage shape

One ETS table (:set, :public, not :named_table -- named tables would be exactly the kind of accidental global-singleton trap this module exists to avoid) holds every piece of registry state, keyed by tagged tuples so unrelated concerns never collide:

  • {:ns, name} -> %{meta:, aliases:, refers:, auto_refer_core?:, var_names:} -- one row per namespace. var_names is a MapSet of the bare names interned directly in this namespace (not refers/aliases), kept alongside the namespace row so require :refer/use can enumerate "every public var this namespace defines" without a table scan.
  • {:var, ns, name} -> %{value:, meta:} -- one row per Logos.Var. Logos.Var itself (lib/logos/var.ex) is just the %{ns, name} identity pair; its value/meta genuinely live here, in the :public table, so a process spawned via spawn/spawn-link/ spawn-monitor can read/write a Var directly without round-tripping through an owning process -- this is the concrete mechanism :public was chosen for.
  • :current_ns -> the default current-namespace name (String.t(), always "user" from new/1), consulted only as the fallback for a BEAM process that has never called set_current_ns!/2 itself. {:current_ns, pid} -> a per-process override: real Clojure's *ns* is thread-local, so the BEAM-native equivalent is process-local. current_ns/1 checks {:current_ns, self()} first and only falls back to the shared :current_ns default row if this process has never set its own -- so two processes (in-ns ...)-ing concurrently against the same Runtime (e.g. after one spawns the other) no longer stomp on each other. A freshly spawned process simply inherits the shared default ("user", or whatever the last unscoped set_current_ns!/2 caller -- realistically only ever the one process that ran before any spawn happened -- last wrote) until it calls in-ns itself, matching a fresh Clojure thread's *ns* starting from a sane default rather than literally inheriting its parent's dynamic binding (real Clojure threads don't inherit *ns* bindings either without explicit bound-fn/binding plumbing). Known accepted tradeoff: {:current_ns, pid} rows are never cleaned up when a process exits -- a long-running embedder that spawns enormous numbers of short-lived processes which each call in-ns will accumulate ETS rows forever. Not fixed here (would need a monitor-and-delete per spawned process, adding real overhead to every spawn for a leak that's bounded by "total processes ever spawned across the Runtime's lifetime", not unbounded within a single process) -- flagged here as a potential future fix if it ever matters in practice.
  • :loading -> a MapSet.t(String.t()) of namespace names currently mid-require, for circular-require detection. See start_loading!/2/finish_loading!/2.
  • :gensym_counter -> an integer counter for gensym.
  • :load_paths -> a fixed (set once at new/1, never mutated) list of directory strings require/use search on disk for a namespace not already in-memory. See load_paths/1 and Logos.Primitives' load_ns!/2.
  • :data_readers -> a %{tag_text => {module, function}} map backing #tag value tagged literals (priv/grammar/logos.aether's tagged_literal rule, resolved at read time by Logos.Reader.Actions's handle_rule(:tagged_literal, ...)). Seeded at new/1 with "inst"/"uuid" (Logos.DataReaders); register_data_reader!/3 (the register-data-reader! primitive's backing call) adds more, always resolved through Logos.Interop.Allowlist first -- never an arbitrary Logos closure, the same sandboxing chokepoint import uses.

Granular per-var/per-ns rows (rather than one giant nested map for the whole registry) are deliberate: two concurrent defs into two different Vars never contend on the same ETS row. Two concurrent writes to the same Var (or the same namespace's aliases/refers/ var_names) are still a read-modify-write race in this implementation (ordinary :ets.lookup + :ets.insert, not :ets.update_element with a match spec). This is a known, currently-unaddressed race now that spawn/spawn-link/spawn-monitor make concurrent writers real: two processes racing to def the same Var, or to alias!/refer! into the same namespace, can lose one write. In practice the stdlib's atom (see concurrency.logos) sidesteps this entirely by funneling all mutation of a given piece of state through one owning process rather than relying on this table's own atomicity.

Summary

Functions

The current (system-argv) value -- see set_argv!/2.

The name of the current namespace for the calling process -- see moduledoc's :current_ns per-process fix.

The {module, function} registered for tag_text (e.g. "inst", "my/tag"), or :error if nothing is registered under that tag -- see moduledoc's :data_readers row.

Clears name's loading mark (call in an after/finally-style cleanup, mirroring require's bracket).

This Runtime's configured require/use search path -- see new/1's :load_paths opt and Logos.Primitives' load_ns!/2. Fixed at new/1 time; there is no setter, matching every other embedder-configured Runtime option.

Whether name is currently mid-require (circular-require detection).

Creates a fresh, isolated Runtime: a new :public/:set ETS table, seeded with an empty logos.core namespace, every Layer-1 primitive (Logos.Primitives.install!/1) interned into it, and a user namespace (auto-referring logos.core) as the starting namespace -- every fresh Runtime starts with "user" as its current namespace. Does not load priv/stdlib/*.logos -- that is Logos.Stdlib.load!/1 (or the Logos.new_runtime/0 convenience, which does both).

A fresh gensym name, e.g. "G__1", "G__2", ... -- unique within this Runtime.

Registers {module, function} under tag_text for #tag_text value tagged literals -- the register-data-reader! primitive's backing call. Overwrites any existing registration for the same tag.

The (system-argv) primitive's backing store -- Mix.Tasks.Logos.Run calls set_argv!/2 once, right after building a fresh Runtime and before evaluating the script, with whatever command-line args followed the script path (mirroring System.argv()). Defaults to [] (set at new/1 time) for every Runtime that never calls this -- e.g. logos.repl.

Sets the current namespace for the calling process only (per moduledoc). Does not create it -- callers (e.g. the in-ns primitive) should Namespace.ensure!/2 first.

Marks name as loading. Returns {:error, {:circular_require, name}} if it is already loading, so a require cycle fails with a clear error instead of hanging or stack-overflowing. Returns an error tuple rather than raising directly, leaving the choice of whether to raise to the caller; Logos.Primitives' require primitive raises.

Types

t()

@type t() :: %Logos.Runtime{table: :ets.tid()}

Functions

argv(runtime)

@spec argv(t()) :: [String.t()]

The current (system-argv) value -- see set_argv!/2.

current_ns(runtime)

@spec current_ns(t()) :: String.t()

The name of the current namespace for the calling process -- see moduledoc's :current_ns per-process fix.

data_reader(runtime, tag_text)

@spec data_reader(t(), String.t()) :: {:ok, {module(), atom()}} | :error

The {module, function} registered for tag_text (e.g. "inst", "my/tag"), or :error if nothing is registered under that tag -- see moduledoc's :data_readers row.

finish_loading!(runtime, name)

@spec finish_loading!(t(), String.t()) :: :ok

Clears name's loading mark (call in an after/finally-style cleanup, mirroring require's bracket).

load_paths(runtime)

@spec load_paths(t()) :: [String.t()]

This Runtime's configured require/use search path -- see new/1's :load_paths opt and Logos.Primitives' load_ns!/2. Fixed at new/1 time; there is no setter, matching every other embedder-configured Runtime option.

loading?(runtime, name)

@spec loading?(t(), String.t()) :: boolean()

Whether name is currently mid-require (circular-require detection).

new(opts \\ [])

@spec new(keyword()) :: t()

Creates a fresh, isolated Runtime: a new :public/:set ETS table, seeded with an empty logos.core namespace, every Layer-1 primitive (Logos.Primitives.install!/1) interned into it, and a user namespace (auto-referring logos.core) as the starting namespace -- every fresh Runtime starts with "user" as its current namespace. Does not load priv/stdlib/*.logos -- that is Logos.Stdlib.load!/1 (or the Logos.new_runtime/0 convenience, which does both).

opts[:load_paths] -- a list of directory strings require/use search (in order, first match wins) when asked for a namespace not already in-memory; see load_paths/1 and Logos.Primitives' load_ns!/2. Defaults to [], meaning require/use only ever succeed against a namespace already loaded in-memory (this Runtime's behavior before file-based loading existed).

next_gensym(runtime, prefix \\ "G__")

@spec next_gensym(t(), String.t()) :: String.t()

A fresh gensym name, e.g. "G__1", "G__2", ... -- unique within this Runtime.

register_data_reader!(runtime, tag_text, arg)

@spec register_data_reader!(t(), String.t(), {module(), atom()}) :: :ok

Registers {module, function} under tag_text for #tag_text value tagged literals -- the register-data-reader! primitive's backing call. Overwrites any existing registration for the same tag.

set_argv!(runtime, argv)

@spec set_argv!(t(), [String.t()]) :: :ok

The (system-argv) primitive's backing store -- Mix.Tasks.Logos.Run calls set_argv!/2 once, right after building a fresh Runtime and before evaluating the script, with whatever command-line args followed the script path (mirroring System.argv()). Defaults to [] (set at new/1 time) for every Runtime that never calls this -- e.g. logos.repl.

set_current_ns!(runtime, name)

@spec set_current_ns!(t(), String.t()) :: :ok

Sets the current namespace for the calling process only (per moduledoc). Does not create it -- callers (e.g. the in-ns primitive) should Namespace.ensure!/2 first.

start_loading!(runtime, name)

@spec start_loading!(t(), String.t()) ::
  :ok | {:error, {:circular_require, String.t()}}

Marks name as loading. Returns {:error, {:circular_require, name}} if it is already loading, so a require cycle fails with a clear error instead of hanging or stack-overflowing. Returns an error tuple rather than raising directly, leaving the choice of whether to raise to the caller; Logos.Primitives' require primitive raises.

Brackets both routes load_ns!/2 can take for a namespace not already in-memory: real file-based loading off disk (following the my-app.core -> my_app/core.logos dot-to-slash/dash-to-underscore naming convention, searched across load_paths/1) recursing into a require of its own inside the loaded file, and the (now purely historical) already-in-memory short-circuit that never actually reaches this function at all. See Logos.Primitives' load_ns!/2 for the full resolution.