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
533 lines
18 KiB
Rust
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(¬ifications);
|
|
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
|
|
);
|
|
}
|
|
}
|