Expose Registrations as a userdata module
Assisted-by: GLM-5.3 via pi
change qmlypxunnxqktspusuyvklrmvntnxpts
commit ab0a49edfd1d1101517aed3500b6f3cd110eb295
author Alpha Chen <alpha@kejadlen.dev>
date
parent vqznpvqs
diff --git a/src/parallax.rs b/src/parallax.rs
index bf9e60e..828b796 100644
--- a/src/parallax.rs
+++ b/src/parallax.rs
@@ -1,15 +1,14 @@
 use camino::Utf8Path;
 use camino::Utf8PathBuf;
 use std::mem;
-use std::sync::Arc;
-use std::sync::Mutex;
-use std::sync::MutexGuard;
 
+use mlua::AnyUserData;
 use mlua::ErrorContext as _;
 use mlua::Function;
 use mlua::Lua;
 use mlua::LuaSerdeExt as _;
 use mlua::Table;
+use mlua::UserData;
 use mlua::Value;
 
 use crate::Error;
@@ -19,8 +18,10 @@ use crate::spec::Setup;
 
 const FENNEL_LUA: &str = include_str!("../vendor/fennel.lua");
 
-/// What a config has registered so far, filled in by the module's
-/// registration functions as the config runs.
+/// What a config has registered so far, filled in as the config
+/// runs. It lives Lua-side as the `parallax` module itself: configs
+/// call its methods, and Rust borrows it back out once the config
+/// finishes.
 #[derive(Default)]
 struct Registrations {
     setup: Option<Setup>,
@@ -28,6 +29,60 @@ struct Registrations {
     flows: Vec<Flow>,
 }
 
+impl Registrations {
+    fn setup(&mut self, lua: &Lua, table: Value) -> Result<(), mlua::Error> {
+        let Value::Table(table) = table else {
+            return Err(error_at(lua, "setup takes a table"));
+        };
+        if self.setup.is_some() {
+            return Err(error_at(lua, "setup must be called exactly once"));
+        }
+        let setup: Setup = lua
+            .from_value(Value::Table(table))
+            .map_err(|error| error_at(lua, &error_message(&error)))?;
+        if setup.viewports.is_empty() {
+            return Err(error_at(lua, "viewports must not be empty"));
+        }
+        self.setup = Some(setup);
+        Ok(())
+    }
+
+    fn server(&mut self, lua: &Lua, call: Value) -> Result<(), mlua::Error> {
+        let Value::Function(call) = call else {
+            return Err(error_at(lua, "server takes a function"));
+        };
+        if self.server.is_some() {
+            return Err(error_at(lua, "server must be called exactly once"));
+        }
+        self.server = Some(call);
+        Ok(())
+    }
+
+    fn flow(&mut self, lua: &Lua, name: Value, call: Value) -> Result<(), mlua::Error> {
+        let name = match name {
+            Value::String(name) => match name.to_str() {
+                Ok(name) => NonEmptyString::new(name.to_owned()),
+                Err(_) => None,
+            },
+            _ => None,
+        };
+        let call = match call {
+            Value::Function(call) => Some(call),
+            _ => None,
+        };
+        let (Some(name), Some(call)) = (name, call) else {
+            return Err(error_at(lua, "flow takes a non-empty name and a function"));
+        };
+        self.flows.push(Flow { name, call });
+        Ok(())
+    }
+}
+
+/// Storage only. The module exposes plain functions, not methods,
+/// because configs destructure it and call them bare — `(setup {...})`
+/// — with no self to bind a userdata method to.
+impl UserData for Registrations {}
+
 /// A loaded parallax: the executable form of a config. The server
 /// function and flows are owned handles into the embedded Lua state,
 /// which stays alive in this struct; fields are ordered so the handles
@@ -55,15 +110,13 @@ impl Parallax {
         // safe default omits. Configs are the user's own trusted files,
         // so the full stdlib is fine here.
         let lua = unsafe { Lua::unsafe_new() };
-        let stash = Arc::new(Mutex::new(Registrations::default()));
         let filename = path.as_str().to_owned();
 
         let Registrations {
             setup,
             server,
             flows,
-        } = run_config(&lua, &source, &filename, &stash)
-            .with_context(|_| format!("loading {path}"))?;
+        } = run_config(&lua, &source, &filename).with_context(|_| format!("loading {path}"))?;
 
         let setup = setup.ok_or_else(|| Error::ConfigRegistration {
             path: path.to_owned(),
@@ -107,80 +160,43 @@ fn default_name(path: &Utf8Path) -> String {
 }
 
 /// Builds the parallax module in Rust and registers it as the
-/// `parallax` entry in package.preload, so configs can require it.
-fn install_module(lua: &Lua, stash: &Arc<Mutex<Registrations>>) -> Result<(), mlua::Error> {
-    let module = lua.create_table()?;
-    module.set("setup", setup_fn(lua, stash.clone())?)?;
-    module.set("server", server_fn(lua, stash.clone())?)?;
-    module.set("flow", flow_fn(lua, stash.clone())?)?;
+/// Builds the `parallax` module in package.preload — a table of
+/// registration functions backed by the Registrations userdata — and
+/// returns the handle so the caller can borrow the registrations back
+/// out after the config runs. The functions raise errors while the
+/// config's stack frame is alive, so failures point at the call site.
+fn install_module(lua: &Lua) -> Result<AnyUserData, mlua::Error> {
+    let module = lua.create_userdata(Registrations::default())?;
+
+    let setup = {
+        let module = module.clone();
+        lua.create_function(move |lua, table: Value| {
+            module.borrow_mut::<Registrations>()?.setup(lua, table)
+        })?
+    };
+    let server = {
+        let module = module.clone();
+        lua.create_function(move |lua, call: Value| {
+            module.borrow_mut::<Registrations>()?.server(lua, call)
+        })?
+    };
+    let flow = {
+        let module = module.clone();
+        lua.create_function(move |lua, (name, call): (Value, Value)| {
+            module.borrow_mut::<Registrations>()?.flow(lua, name, call)
+        })?
+    };
 
+    let table = lua.create_table()?;
+    table.set("setup", setup)?;
+    table.set("server", server)?;
+    table.set("flow", flow)?;
+
+    let lookup = lua.create_function(move |_, _name: String| Ok(table.clone()))?;
     let package: Table = lua.globals().get("package")?;
     let preload: Table = package.get("preload")?;
-    let lookup = lua.create_function(move |_, _name: String| Ok(module.clone()))?;
     preload.set("parallax", lookup)?;
-    Ok(())
-}
-
-/// The registration functions raise errors while the config's stack
-/// frame is alive, so failures point at the registration call site.
-fn setup_fn(lua: &Lua, stash: Arc<Mutex<Registrations>>) -> Result<Function, mlua::Error> {
-    lua.create_function(move |lua, table: Value| {
-        let Value::Table(table) = table else {
-            return Err(error_at(lua, "setup takes a table"));
-        };
-        if lock(&stash)?.setup.is_some() {
-            return Err(error_at(lua, "setup must be called exactly once"));
-        }
-        let setup: Setup = lua
-            .from_value(Value::Table(table))
-            .map_err(|error| error_at(lua, &error_message(&error)))?;
-        if setup.viewports.is_empty() {
-            return Err(error_at(lua, "viewports must not be empty"));
-        }
-        lock(&stash)?.setup = Some(setup);
-        Ok(())
-    })
-}
-
-fn server_fn(lua: &Lua, stash: Arc<Mutex<Registrations>>) -> Result<Function, mlua::Error> {
-    lua.create_function(move |lua, call: Value| {
-        let Value::Function(call) = call else {
-            return Err(error_at(lua, "server takes a function"));
-        };
-        let mut registrations = lock(&stash)?;
-        if registrations.server.is_some() {
-            return Err(error_at(lua, "server must be called exactly once"));
-        }
-        registrations.server = Some(call);
-        Ok(())
-    })
-}
-
-fn flow_fn(lua: &Lua, stash: Arc<Mutex<Registrations>>) -> Result<Function, mlua::Error> {
-    lua.create_function(move |lua, (name, call): (Value, Value)| {
-        let name = match name {
-            Value::String(name) => match name.to_str() {
-                Ok(name) => NonEmptyString::new(name.to_owned()),
-                Err(_) => None,
-            },
-            _ => None,
-        };
-        let call = match call {
-            Value::Function(call) => Some(call),
-            _ => None,
-        };
-        let (Some(name), Some(call)) = (name, call) else {
-            return Err(error_at(lua, "flow takes a non-empty name and a function"));
-        };
-        lock(&stash)?.flows.push(Flow { name, call });
-        Ok(())
-    })
-}
-
-fn lock(stash: &Arc<Mutex<Registrations>>) -> Result<MutexGuard<'_, Registrations>, mlua::Error> {
-    stash
-        .lock()
-        .map_err(|_| mlua::Error::runtime("the registration state was poisoned"))
+    Ok(module)
 }
 
 fn error_at(lua: &Lua, message: &str) -> mlua::Error {
@@ -219,14 +235,9 @@ fn caller_position(lua: &Lua) -> Option<String> {
 /// Loads the Fennel compiler, runs the config, and returns everything
 /// it registered. Every failure surfaces as an mlua error; the caller
 /// annotates it with the config's path.
-fn run_config(
-    lua: &Lua,
-    source: &str,
-    filename: &str,
-    stash: &Arc<Mutex<Registrations>>,
-) -> Result<Registrations, mlua::Error> {
+fn run_config(lua: &Lua, source: &str, filename: &str) -> Result<Registrations, mlua::Error> {
     let fennel: Table = lua.load(FENNEL_LUA).set_name("fennel.lua").eval()?;
-    install_module(lua, stash)?;
+    let module = install_module(lua)?;
 
     // Configs end with a registration call, which compiles to a tail
     // call; tail elimination destroys the config's stack frame, hiding
@@ -248,7 +259,9 @@ fn run_config(
         .set_name(format!("@{filename}"))
         .eval::<()>()?;
 
-    Ok(mem::take(&mut *lock(stash)?))
+    // A failed borrow means a config re-entered registration while it
+    // was already being mutated — surface it instead of hanging.
+    Ok(mem::take(&mut *module.borrow_mut::<Registrations>()?))
 }
 
 // The index arithmetic in this helper is safe because `index` comes from