Files
MetaCrate/crates/libremetaverse/src/lsl_syntax.rs
Chili Palmer c9a1170a27
Some checks failed
API and SemVer surface / api-surface (push) Failing after 1m34s
Native code generation / deterministic (push) Has been cancelled
Concurrency and resource soak audit / soak (push) Has been cancelled
Documentation / documentation (push) Has been cancelled
performance evidence / audit (push) Has been cancelled
First release candidate / non-fuzz-release-gate (push) Has been cancelled
Release platform and feature matrix / audit (push) Has been cancelled
Release platform and feature matrix / matrix (false, linux-stable-minimal, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, macos-stable-portable, x86_64-apple-darwin, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, windows-stable-portable, x86_64-pc-windows-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-msrv-portable, x86_64-unknown-linux-gnu, 1.96.0) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-default, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-features, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-release-surface, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Dependency and supply-chain audit / audit (push) Has been cancelled
Native release artifact audit / audit (push) Has been cancelled
Imaging and meshing gate / native (push) Failing after 53s
JPEG 2000 feature / linux (push) Successful in 2m49s
Native Rust workspace compile / compile (push) Failing after 59s
Skia feature / linux (push) Has been cancelled
Complete first release candidate audit (#107)
2026-08-12 14:44:28 +00:00

533 lines
18 KiB
Rust

//! Runtime LSL keyword catalog with OpenSim capability refresh support.
#![allow(clippy::missing_errors_doc)] // Public signatures mirror the compatibility map.
use std::collections::HashMap;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use std::time::Duration;
use base64::Engine as _;
use flate2::read::GzDecoder;
use libremetaverse_structured_data::{OSD, OSDParser};
use libremetaverse_types::UUID;
use libremetaverse_types::compat::{
CancellationToken, EventHandler, FrozenDictionary, Subscription, Uri,
};
use crate::{Error, GridClient, LslSyntaxLslCategory, LslSyntaxLslKeyword, Simulator};
const VERSION_KEY: &str = "llsd-lsl-syntax-version";
const SYNTAX_FEATURE: &str = "LSLSyntaxId";
const SYNTAX_CAPABILITY: &str = "LSLSyntax";
const MAX_SYNTAX_BYTES: usize = 8 * 1024 * 1024;
const FETCH_WAIT: Duration = Duration::from_secs(20);
const DEFAULT_SYNTAX_GZIP_BASE64: &str =
include_str!("../resources/keywords_lsl_default.xml.gz.b64");
static KEYWORDS: OnceLock<RwLock<HashMap<String, LslSyntaxLslKeyword>>> = OnceLock::new();
fn keyword_store() -> &'static RwLock<HashMap<String, LslSyntaxLslKeyword>> {
KEYWORDS.get_or_init(|| RwLock::new(HashMap::new()))
}
struct SyntaxEvents {
next_id: AtomicU64,
handlers: Mutex<Vec<(u64, EventHandler<()>)>>,
}
impl Default for SyntaxEvents {
fn default() -> Self {
Self {
next_id: AtomicU64::new(1),
handlers: Mutex::new(Vec::new()),
}
}
}
struct LslSyntaxInner {
events: Arc<SyntaxEvents>,
client: Mutex<Option<GridClient>>,
syntax_id: Mutex<UUID>,
subscriptions: Mutex<Vec<Subscription>>,
}
/// Process-wide LSL keyword catalog and per-registration change notification.
#[derive(Clone)]
pub struct LslSyntax(Arc<LslSyntaxInner>);
impl LslSyntax {
pub fn new_with_constructor() -> Result<Self, Error> {
let syntax = Self::empty();
syntax.load_default()?;
Ok(syntax)
}
pub fn new_with_grid_client(client: GridClient) -> Result<Self, Error> {
let syntax = Self::empty();
syntax.register(client)?;
Ok(syntax)
}
fn empty() -> Self {
Self(Arc::new(LslSyntaxInner {
events: Arc::new(SyntaxEvents::default()),
client: Mutex::new(None),
syntax_id: Mutex::new(UUID::zero()),
subscriptions: Mutex::new(Vec::new()),
}))
}
pub fn subscribe_syntax_changed(&self, handler: EventHandler<()>) -> Subscription {
let id = self.0.events.next_id.fetch_add(1, Ordering::Relaxed);
self.0
.events
.handlers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push((id, handler));
let events = Arc::downgrade(&self.0.events);
Subscription::new(move || {
if let Some(events) = events.upgrade() {
events
.handlers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.retain(|(candidate, _)| *candidate != id);
}
})
}
pub fn keywords() -> FrozenDictionary<String, LslSyntaxLslKeyword> {
FrozenDictionary(
keyword_store()
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone(),
)
}
pub fn register(&self, client: GridClient) -> Result<(), Error> {
*self
.0
.client
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(client.clone());
// Always establish a useful catalog, including on OpenSim grids that
// do not advertise the optional LSLSyntax capability.
self.load_default()?;
let inner = Arc::clone(&self.0);
let network = client.native_network()?;
let client_for_change = client.clone();
let changed = network.native_subscribe_sim_changed(Arc::new(move |_| {
if let Ok(network) = client_for_change.native_network()
&& let Some(simulator) = network.native_current_sim()
{
LslSyntaxInner::observe_simulator(&inner, simulator, false);
}
}));
self.retain_subscription(changed);
if let Some(simulator) = network.native_current_sim() {
LslSyntaxInner::observe_simulator(&self.0, simulator, true);
}
Ok(())
}
fn retain_subscription(&self, subscription: Subscription) {
self.0
.subscriptions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(subscription);
}
fn load_default(&self) -> Result<(), Error> {
let compact = DEFAULT_SYNTAX_GZIP_BASE64
.split_ascii_whitespace()
.collect::<String>();
let compressed = base64::engine::general_purpose::STANDARD
.decode(compact)
.map_err(|_| Error::InvalidOperation)?;
let mut decoder = GzDecoder::new(compressed.as_slice());
let mut bytes = Vec::new();
decoder
.by_ref()
.take((MAX_SYNTAX_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.map_err(|_| Error::InvalidOperation)?;
if bytes.len() > MAX_SYNTAX_BYTES {
return Err(Error::Argument);
}
self.0.parse_and_publish(&bytes)
}
}
impl LslSyntaxInner {
fn observe_simulator(this: &Arc<Self>, simulator: Simulator, wait: bool) {
if let Some(caps) = simulator.native_caps() {
let weak = Arc::downgrade(this);
let subscription = caps.subscribe_capabilities_received(Some(Arc::new(move |args| {
if let Some(inner) = weak.upgrade() {
inner.refresh(args.simulator(), false);
}
})));
this.subscriptions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(subscription);
}
this.refresh(simulator, wait);
}
fn refresh(self: &Arc<Self>, simulator: Simulator, wait: bool) {
let syntax_id = match simulator.features.get(SYNTAX_FEATURE.to_owned()) {
Ok(Some(value)) => match value.as_uuid() {
Ok(value) => value,
Err(_) => return,
},
_ => return,
};
if *self
.syntax_id
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
== syntax_id
{
return;
}
let Some(client) = self
.client
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
else {
return;
};
let cache = cache_path(&client, syntax_id);
if let Ok(bytes) = std::fs::read(&cache)
&& self.parse_and_publish(&bytes).is_ok()
{
*self
.syntax_id
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = syntax_id;
return;
}
let Ok(Some(uri)) = simulator.native_capability_uri(SYNTAX_CAPABILITY) else {
return;
};
self.fetch(client, uri, syntax_id, cache, wait);
}
fn fetch(
self: &Arc<Self>,
client: GridClient,
uri: Uri,
syntax_id: UUID,
cache: PathBuf,
wait: bool,
) {
let inner = Arc::clone(self);
let (completed, receiver) = std::sync::mpsc::sync_channel(1);
let spawn = std::thread::Builder::new()
.name("lsl-syntax-fetch".to_owned())
.spawn(move || {
let outcome = fetch_bytes(&client, uri).and_then(|bytes| {
inner.parse_and_publish(&bytes)?;
persist_cache(&cache, &bytes)?;
*inner
.syntax_id
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = syntax_id;
Ok(())
});
let _ = completed.send(outcome);
});
if spawn.is_err() {
return;
}
if wait {
let _ = receiver.recv_timeout(FETCH_WAIT);
}
}
fn parse_and_publish(&self, bytes: &[u8]) -> Result<(), Error> {
if bytes.is_empty() || bytes.len() > MAX_SYNTAX_BYTES {
return Err(Error::Argument);
}
let OSD::Map(root) = OSDParser::deserialize_with_bytes(bytes.to_vec())? else {
return Err(Error::Argument);
};
let parsed = parse_keywords(&root)?;
if parsed.is_empty() {
return Err(Error::Argument);
}
*keyword_store()
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = parsed;
let handlers = self
.events
.handlers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.map(|(_, handler)| Arc::clone(handler))
.collect::<Vec<_>>();
for handler in handlers {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handler(())));
}
Ok(())
}
}
fn fetch_bytes(client: &GridClient, uri: Uri) -> Result<Vec<u8>, Error> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|_| Error::InvalidOperation)?;
let http = client.native_http_caps_client();
let (response, bytes) = runtime.block_on(http.get(uri, CancellationToken::default(), None))?;
if !response.is_success_status_code() || bytes.is_empty() || bytes.len() > MAX_SYNTAX_BYTES {
return Err(Error::InvalidOperation);
}
Ok(bytes)
}
fn cache_path(client: &GridClient, syntax_id: UUID) -> PathBuf {
Path::new(&client.settings_ref().asset_cache.dir).join(format!("keywords_lsl_{syntax_id}.xml"))
}
fn persist_cache(path: &Path, bytes: &[u8]) -> Result<(), Error> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|_| Error::InvalidOperation)?;
}
std::fs::write(path, bytes).map_err(|_| Error::InvalidOperation)
}
fn parse_keywords(
root: &HashMap<String, OSD>,
) -> Result<HashMap<String, LslSyntaxLslKeyword>, Error> {
if root
.get(VERSION_KEY)
.map(OSD::as_integer)
.transpose()?
.is_some_and(|version| version != 2)
{
return Err(Error::Argument);
}
let mut keywords = HashMap::new();
let mut groups = root.iter().collect::<Vec<_>>();
groups.sort_by(|left, right| left.0.cmp(right.0));
for (group, value) in groups {
if group == VERSION_KEY {
continue;
}
let OSD::Map(items) = value else {
continue;
};
let Some(category) = category(group) else {
continue;
};
let mut items = items.iter().collect::<Vec<_>>();
items.sort_by(|left, right| left.0.cmp(right.0));
for (keyword, value) in items {
let OSD::Map(attributes) = value else {
continue;
};
let tooltip = tooltip(group, keyword, attributes)?;
let value = LslSyntaxLslKeyword {
category,
keyword: keyword.clone(),
tooltip,
deprecated: boolean_attribute(attributes, "deprecated")?,
god_mode: boolean_attribute(attributes, "god-mode")?,
};
if keywords.insert(keyword.clone(), value).is_some() {
return Err(Error::Argument);
}
}
}
Ok(keywords)
}
fn category(group: &str) -> Option<LslSyntaxLslCategory> {
match group {
"controls" => Some(LslSyntaxLslCategory::Control),
"types" => Some(LslSyntaxLslCategory::Datatype),
"constants" => Some(LslSyntaxLslCategory::Constant),
"events" => Some(LslSyntaxLslCategory::Event),
"functions" => Some(LslSyntaxLslCategory::Function),
_ => None,
}
}
fn tooltip(group: &str, keyword: &str, attributes: &HashMap<String, OSD>) -> Result<String, Error> {
let mut output = attributes
.get("tooltip")
.map(OSD::as_string)
.transpose()?
.unwrap_or_default();
if attributes.contains_key("tooltip") {
output.push('\n');
}
match group {
"constants" => {
let type_ = string_attribute(attributes, "type")?;
let value = string_attribute(attributes, "value")?;
output.push_str(&format!(" Type: {type_}-{value}"));
}
"events" => {
output.push_str(&format!(
"{keyword} ({})",
parse_arguments(attributes.get("arguments"))?
));
}
"functions" => {
let return_ = string_attribute(attributes, "return")?;
output.push_str(&format!(
"{return_} {keyword} ({})\n",
parse_arguments(attributes.get("arguments"))?
));
let energy = attributes
.get("energy")
.map(OSD::as_string)
.transpose()?
.unwrap_or_else(|| "0.0".to_owned());
output.push_str(&format!("Energy: {energy}"));
if let Some(sleep) = attributes.get("sleep") {
output.push_str(&format!(", Sleep: {}", sleep.as_string()?));
}
}
_ => {}
}
Ok(output)
}
fn parse_arguments(value: Option<&OSD>) -> Result<String, Error> {
let Some(OSD::Array(arguments)) = value else {
return Ok(String::new());
};
let mut rendered = Vec::new();
for argument in arguments {
let OSD::Map(argument) = argument else {
continue;
};
let mut values = argument.iter().collect::<Vec<_>>();
values.sort_by(|left, right| left.0.cmp(right.0));
for (name, type_) in values {
rendered.push(format!("{} {name}", type_.as_string()?));
}
}
Ok(rendered.join(", "))
}
fn string_attribute(attributes: &HashMap<String, OSD>, name: &str) -> Result<String, Error> {
attributes
.get(name)
.map(OSD::as_string)
.transpose()
.map(Option::unwrap_or_default)
.map_err(Into::into)
}
fn boolean_attribute(attributes: &HashMap<String, OSD>, name: &str) -> Result<bool, Error> {
attributes
.get(name)
.map(OSD::as_boolean)
.transpose()
.map(|value| value.unwrap_or(false))
.map_err(Into::into)
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
static TEST_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn embedded_catalog_is_complete_and_typed() {
let _guard = TEST_LOCK.lock().unwrap();
let _syntax = LslSyntax::new_with_constructor().unwrap();
let keywords = LslSyntax::keywords().0;
assert!(keywords.len() > 500);
assert_eq!(
keywords.get("llSay").unwrap().category,
LslSyntaxLslCategory::Function
);
assert_eq!(
keywords.get("state_entry").unwrap().category,
LslSyntaxLslCategory::Event
);
assert_eq!(
keywords.get("integer").unwrap().category,
LslSyntaxLslCategory::Datatype
);
}
#[test]
fn parsed_catalog_replaces_snapshot_and_notifies_subscribers() {
let _guard = TEST_LOCK.lock().unwrap();
let syntax = LslSyntax::empty();
let notifications = Arc::new(AtomicUsize::new(0));
let observed = Arc::clone(&notifications);
let _subscription = syntax.subscribe_syntax_changed(Arc::new(move |()| {
observed.fetch_add(1, Ordering::Relaxed);
}));
let function = OSD::Map(HashMap::from([
("return".to_owned(), OSD::String("integer".to_owned())),
("energy".to_owned(), OSD::String("10.0".to_owned())),
("deprecated".to_owned(), OSD::Boolean(true)),
(
"arguments".to_owned(),
OSD::Array(vec![OSD::Map(HashMap::from([(
"message".to_owned(),
OSD::String("string".to_owned()),
)]))]),
),
]));
let document = OSD::Map(HashMap::from([
(VERSION_KEY.to_owned(), OSD::Integer(2)),
(
"functions".to_owned(),
OSD::Map(HashMap::from([("osTest".to_owned(), function)])),
),
]));
let bytes = OSDParser::serialize_llsd_xml_bytes(document).unwrap();
syntax.0.parse_and_publish(&bytes).unwrap();
assert_eq!(notifications.load(Ordering::Relaxed), 1);
let keywords = LslSyntax::keywords().0;
let keyword = keywords.get("osTest").unwrap();
assert!(keyword.deprecated);
assert_eq!(keyword.category, LslSyntaxLslCategory::Function);
assert!(keyword.tooltip.contains("integer osTest (string message)"));
assert!(keyword.tooltip.contains("Energy: 10.0"));
}
#[test]
fn registration_on_opensim_without_syntax_capability_uses_default_catalog() {
let _guard = TEST_LOCK.lock().unwrap();
let client = GridClient::builder().build().unwrap();
let syntax = LslSyntax::new_with_grid_client(client).unwrap();
assert!(LslSyntax::keywords().0.contains_key("llOwnerSay"));
assert_eq!(
syntax
.0
.subscriptions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len(),
1
);
}
}