lua: a compiled chunk is kept, so a load parses only what changed
The compile is a UI load's biggest removable cost and the only half of it that can be kept — running the chunks is what an addon *is*. Measured through the addon harness's `--together` load of the player's own 91 addons, three passes: ``` no cache 4,788 chunks compiled 3,324 ms cold 1,592 compiled, 3,196 from cache 1,482 ms warm 0 compiled, 4,788 from cache 471 ms (+24 ms of keys) ``` **About 940 ms off every login and every `/reload`**, and the first load after an addon changes pays only for the files that changed. `benilla-config/cache/lua/`, one file per chunk, keyed by a hash of the chunk's own bytes and its name. Both chunk paths go through it — `run_chunk_named` and the XML loader's own runner, which is how the biggest files in the corpus arrive (pfQuest's database is 33 MiB of `<Include>`d Lua). It cannot go wrong quietly. A key is a property of the bytes, so an edited file is a different entry and a stale one is never asked for; an entry that is truncated, corrupt or from another Lua build fails `lua_undump`'s header check and the source is parsed instead, so a bad entry costs the parse it meant to save. The dump keeps its debug info — `debugstack` is an addon-facing verb and a stripped chunk would lie about its own lines — which the test asserts by raising from a cached chunk and demanding its file and line back. The folder is capped at 512 MiB (one load's worth is 159 MiB) and emptied wholesale when it is over: a cache that needed an eviction policy would need bookkeeping that costs more than it saves. The first build of this looked like it worked and saved nothing, because `ahash::AHasher::default()` is seeded per process: every session hashed the same file to a different name, found nothing and rewrote the whole cache (4,776 entries, 477 MiB, a steady 1,592 misses a run). Fixed seeds, and the instrument that caught it — `benilla_ui::script::chunk_cache_line()`, hits and misses with the time each spent — stays, because "the cache is installed" and "the cache is working" turned out to be different claims. Verified: the harness's verdicts over all 91 addons are **identical** with the cache and with `WOW_LUA_CACHE=0`, the full gate chain is green, and the `WOW_DATA` failure sets are unchanged (benilla-app 270, benilla-formats 80). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
Generated
+1
@@ -505,6 +505,7 @@ dependencies = [
|
||||
name = "benilla-app"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"assert_no_alloc",
|
||||
|
||||
@@ -22,6 +22,8 @@ anyhow.workspace = true
|
||||
# The EditBox clipboard, on the main thread as NSPasteboard requires; bevy_egui's 3.x.
|
||||
arboard = "3"
|
||||
benilla-ui = { path = "../benilla-ui" }
|
||||
# The chunk cache's key: a load hashes 70 MiB of addon Lua, so the hash has to be the cheap part.
|
||||
ahash = "0.8.12"
|
||||
bevy.workspace = true
|
||||
wgpu.workspace = true
|
||||
bevy_egui = { version = "0.39", optional = true }
|
||||
|
||||
@@ -197,6 +197,14 @@ fn main() {
|
||||
};
|
||||
let rest: Vec<String> = args.collect();
|
||||
|
||||
// The same compiled-chunk cache the client installs, so a harness run measures what a player's
|
||||
// load costs; `WOW_LUA_CACHE=0` opts out, which is how the cache's own win is measured.
|
||||
if std::env::var("WOW_LUA_CACHE").as_deref() != Ok("0") {
|
||||
benilla_ui::script::install_chunk_cache(Box::new(
|
||||
benilla_app::ui_script::chunk_cache::LuaChunkCache::open(),
|
||||
));
|
||||
}
|
||||
|
||||
// `--surface` needs no corpus, so it is handled before the root is used.
|
||||
if root == "--surface" || rest.iter().any(|a| a == "--surface") {
|
||||
let dump_path = rest
|
||||
@@ -244,6 +252,7 @@ fn main() {
|
||||
// `WOW_UI_VERBS=1` turns this run into the load's per-verb bill: the whole folder in one
|
||||
// VM is the closest thing to a login burst that needs no server.
|
||||
benilla_ui::script::verb_prof::log_report("the whole folder in one VM", 24);
|
||||
eprintln!("[{}]", benilla_ui::script::chunk_cache_line());
|
||||
if rows.is_empty() {
|
||||
eprintln!(
|
||||
"no addons under {} — is that an AddOns folder?",
|
||||
|
||||
@@ -188,7 +188,7 @@ mod ui_quest_log;
|
||||
mod ui_quest_share;
|
||||
mod ui_reputation;
|
||||
mod ui_saved;
|
||||
mod ui_script;
|
||||
pub mod ui_script;
|
||||
mod ui_session;
|
||||
mod ui_shapeshift;
|
||||
mod ui_social;
|
||||
@@ -408,6 +408,12 @@ pub fn run(build: BuildId) -> AppExit {
|
||||
start: run_mode::start_state(),
|
||||
});
|
||||
|
||||
// The compiled-chunk cache, before anything loads a chunk: a UI load's biggest removable cost
|
||||
// is the Lua compile, and a compile is the same answer every time (`ui_script::chunk_cache`).
|
||||
benilla_ui::script::install_chunk_cache(
|
||||
Box::new(ui_script::chunk_cache::LuaChunkCache::open()),
|
||||
);
|
||||
|
||||
// The session's own log file, opened once the subscriber exists so the backlog replays into
|
||||
// it. A player who sees something wrong for two seconds and quits can only be answered from
|
||||
// what the client said at the time, and until now that lived in the terminal it was launched
|
||||
|
||||
@@ -209,6 +209,12 @@ pub(crate) fn legacy_item_cache_path(realm: &str) -> Option<PathBuf> {
|
||||
)
|
||||
}
|
||||
|
||||
/// `benilla-config/cache/lua/`: compiled Lua chunks ([`crate::ui_script::chunk_cache`]).
|
||||
/// Disposable — deleting it costs one slow UI load.
|
||||
pub(crate) fn lua_cache_dir() -> Option<PathBuf> {
|
||||
home().map(|h| h.join("cache").join("lua"))
|
||||
}
|
||||
|
||||
/// `benilla-config/Logs/`: `WoWChatLog.txt` and `WoWCombatLog.txt`, the reference's names.
|
||||
pub(crate) fn logs_dir() -> Option<PathBuf> {
|
||||
home().map(|h| h.join("Logs"))
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! The compiled-chunk cache: `benilla-config/cache/lua/`, one file per chunk.
|
||||
//!
|
||||
//! A UI load's biggest removable cost is the Lua compile. Over the player's enabled addons — 1,471
|
||||
//! files, 70 MiB — **parsing is 703 ms and loading the same chunks as 5.1 bytecode is 74 ms**.
|
||||
//! Running them is the other half of the load and is not cacheable: the tables they build are what
|
||||
//! an addon *is*. So this keeps the compile and nothing else.
|
||||
//!
|
||||
//! **Why it cannot go wrong quietly.** The key is a hash of the chunk's own bytes and its name, so
|
||||
//! an edited file is a different entry and a stale one is simply never asked for. A file that is
|
||||
//! asked for and is truncated, corrupt, or from another Lua build fails `lua_undump`'s header
|
||||
//! check, and [`UiScript::run_chunk_cached`](benilla_ui::script::UiScript::run_chunk_cached) parses
|
||||
//! the source instead — a bad entry costs the parse it meant to save, never a wrong chunk.
|
||||
//!
|
||||
//! The folder is disposable: deleting it costs one slow load. It is capped at [`CAP_BYTES`],
|
||||
//! checked once per session, and emptied wholesale when it is over — a cache that needs an
|
||||
//! eviction policy would need bookkeeping that costs more than it saves.
|
||||
|
||||
use bevy::prelude::info;
|
||||
use std::hash::{BuildHasher, Hash, Hasher};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use benilla_ui::script::ChunkCache;
|
||||
|
||||
/// How much compiled Lua the folder may hold before a session empties it: the player's own set
|
||||
/// dumps to about 85 MiB, so this is a few interface generations' worth.
|
||||
pub const CAP_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
/// The on-disk cache, or a no-op when there is no state folder (a capture run).
|
||||
pub struct LuaChunkCache {
|
||||
dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl LuaChunkCache {
|
||||
/// Open (and if it is over [`CAP_BYTES`], empty) the cache folder.
|
||||
pub fn open() -> LuaChunkCache {
|
||||
let Some(dir) = crate::local_state::lua_cache_dir() else {
|
||||
return LuaChunkCache { dir: None };
|
||||
};
|
||||
if let Ok(size) = folder_bytes(&dir) {
|
||||
if size > CAP_BYTES {
|
||||
info!(
|
||||
"lua cache: {} MiB over the {} MiB cap — emptied",
|
||||
size / (1024 * 1024),
|
||||
CAP_BYTES / (1024 * 1024)
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return LuaChunkCache { dir: None };
|
||||
}
|
||||
LuaChunkCache { dir: Some(dir) }
|
||||
}
|
||||
|
||||
fn path(&self, key: &str) -> Option<PathBuf> {
|
||||
self.dir.as_ref().map(|d| d.join(format!("{key}.luac")))
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder's size, files only, one level deep: the cache writes no subdirectories.
|
||||
fn folder_bytes(dir: &std::path::Path) -> std::io::Result<u64> {
|
||||
let mut total = 0;
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
total += entry?.metadata()?.len();
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
impl ChunkCache for LuaChunkCache {
|
||||
/// The chunk's bytes and its name. The name is in the dump (it is what a traceback prints),
|
||||
/// so two files with the same text under different names are different entries.
|
||||
fn key(&self, source: &[u8], name: &str) -> String {
|
||||
// Fixed seeds, not `AHasher::default()`: that one is seeded per process, so every session
|
||||
// would hash the same file to a different name, find nothing, and rewrite the whole cache
|
||||
// — which is exactly what the first build of this did (4,776 entries, 477 MiB, and a
|
||||
// steady 1,592 misses a run). A cache key has to be a property of the bytes alone.
|
||||
let mut h = ahash::RandomState::with_seeds(
|
||||
0x6265_6e69_6c6c_6100,
|
||||
0x6c75_615f_6368_756e,
|
||||
0x6b5f_6361_6368_6531,
|
||||
0x0000_0000_0000_0001,
|
||||
)
|
||||
.build_hasher();
|
||||
source.hash(&mut h);
|
||||
name.hash(&mut h);
|
||||
format!("{:016x}", h.finish())
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Option<Vec<u8>> {
|
||||
std::fs::read(self.path(key)?).ok()
|
||||
}
|
||||
|
||||
fn put(&self, key: &str, bytecode: &[u8]) {
|
||||
let Some(path) = self.path(key) else {
|
||||
return;
|
||||
};
|
||||
// Written through a temp file in the same folder: a half-written entry that a crash left
|
||||
// behind would be read back whole by its hash name and only then fail its header check.
|
||||
let tmp = path.with_extension("tmp");
|
||||
if std::fs::write(&tmp, bytecode).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &path);
|
||||
}
|
||||
}
|
||||
|
||||
fn forget(&self, key: &str) {
|
||||
if let Some(path) = self.path(key) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use benilla_world::schedule::WorldStage;
|
||||
|
||||
/// The addon folder: discovery, manifests, the enable file and the load walk.
|
||||
pub(crate) mod addons;
|
||||
pub mod chunk_cache;
|
||||
mod content;
|
||||
pub(crate) mod extract;
|
||||
mod flash_trace;
|
||||
|
||||
@@ -49,10 +49,10 @@ fn main() -> mlua::Result<()> {
|
||||
|
||||
// 1 · a VM per file.
|
||||
println!(
|
||||
"{:>8} {:>9} {:>9} {:>9} {:>9} file",
|
||||
"MiB", "parse ms", "exec ms", "dump ms", "bc ms"
|
||||
"{:>8} {:>9} {:>9} {:>9} {:>9} {:>9} {:>6} file",
|
||||
"MiB", "parse ms", "exec ms", "dump ms", "bc ms", "bc+dbg", "MiB"
|
||||
);
|
||||
let mut tot = [0.0f64; 5]; // MiB, parse, exec, dump, bytecode-compile
|
||||
let mut tot = [0.0f64; 6]; // MiB, parse, exec, dump, bytecode-compile, with debug info
|
||||
for (path, src) in &sources {
|
||||
let lua = vm();
|
||||
let t = Instant::now();
|
||||
@@ -75,10 +75,20 @@ fn main() -> mlua::Result<()> {
|
||||
let t = Instant::now();
|
||||
let bc = f.dump(true);
|
||||
let dump = ms(t);
|
||||
// The cache cannot strip: `debugstack` is an addon-facing verb, so the kept-debug-info
|
||||
// dump is the one that matters. Both are timed, because the difference decides whether a
|
||||
// cache can pay for itself at all.
|
||||
let bc_full = f.dump(false);
|
||||
drop(lua);
|
||||
|
||||
let lua = vm();
|
||||
let t = Instant::now();
|
||||
lua.load(&bc_full[..])
|
||||
.set_name(path.as_str())
|
||||
.set_mode(mlua::ChunkMode::Binary)
|
||||
.into_function()?;
|
||||
let bcfull_load = ms(t);
|
||||
let t = Instant::now();
|
||||
lua.load(&bc[..])
|
||||
.set_name(path.as_str())
|
||||
.set_mode(mlua::ChunkMode::Binary)
|
||||
@@ -88,22 +98,28 @@ fn main() -> mlua::Result<()> {
|
||||
|
||||
let fmib = src.len() as f64 / (1024.0 * 1024.0);
|
||||
println!(
|
||||
"{fmib:8.2} {parse:9.1} {exec:9.1} {dump:9.1} {bcload:9.1} {path}{}",
|
||||
"{fmib:8.2} {parse:9.1} {exec:9.1} {dump:9.1} {bcload:9.1} {bcfull_load:9.1} {:7.2} {path}{}",
|
||||
bc_full.len() as f64 / (1024.0 * 1024.0),
|
||||
if ran { "" } else { " [did not run]" }
|
||||
);
|
||||
for (slot, v) in tot.iter_mut().zip([fmib, parse, exec, dump, bcload]) {
|
||||
for (slot, v) in tot
|
||||
.iter_mut()
|
||||
.zip([fmib, parse, exec, dump, bcload, bcfull_load])
|
||||
{
|
||||
*slot += v;
|
||||
}
|
||||
}
|
||||
let [mib_sum, parse, exec, dump, bcload] = tot;
|
||||
let [mib_sum, parse, exec, dump, bcload, bcfull] = tot;
|
||||
println!(
|
||||
"{mib_sum:8.2} {parse:9.1} {exec:9.1} {dump:9.1} {bcload:9.1} — a VM per file, {} files",
|
||||
"{mib_sum:8.2} {parse:9.1} {exec:9.1} {dump:9.1} {bcload:9.1} {bcfull:9.1} — a VM per file, {} files",
|
||||
files.len()
|
||||
);
|
||||
println!(
|
||||
" parse+exec {:.0} ms; with the parse served from a bytecode cache {:.0} ms\n",
|
||||
" parse+exec {:.0} ms; from a STRIPPED cache {:.0} ms; from one that KEEPS debug info \
|
||||
(what `debugstack` needs) {:.0} ms\n",
|
||||
parse + exec,
|
||||
bcload + exec
|
||||
bcload + exec,
|
||||
bcfull + exec
|
||||
);
|
||||
|
||||
// 2 and 3 · one VM for the whole set, as a load does it.
|
||||
|
||||
@@ -261,11 +261,9 @@ impl Loader<'_> {
|
||||
|
||||
/// Run a chunk under a complete chunk name, for a producer whose name is not a path.
|
||||
fn run_named(&self, chunk: &[u8], name: &str) -> mlua::Result<()> {
|
||||
self.lua
|
||||
.load(chunk)
|
||||
.set_name(name)
|
||||
.set_mode(mlua::ChunkMode::Text)
|
||||
.exec()
|
||||
// Through the chunk cache, like every other file chunk: an XML `<Include>` of a `.lua` is
|
||||
// how the biggest data files in the corpus load (pfQuest's database is 33 MiB of them).
|
||||
crate::script::run_source_cached(self.lua, chunk, name)
|
||||
}
|
||||
|
||||
fn model(&self) -> mlua::AppDataRefMut<'_, crate::script::Model> {
|
||||
|
||||
@@ -815,13 +815,10 @@ impl UiScript {
|
||||
self.run_chunk_named(chunk, "(chunk)")
|
||||
}
|
||||
|
||||
/// Run a chunk under the name the 1.12 client gives it ([`addon_chunk_name`] for addon files).
|
||||
/// Run a chunk under the name the 1.12 client gives it ([`addon_chunk_name`] for addon files),
|
||||
/// through the installed [`ChunkCache`] when there is one ([`run_source_cached`]).
|
||||
pub fn run_chunk_named(&self, chunk: &[u8], name: &str) -> mlua::Result<()> {
|
||||
self.lua
|
||||
.load(crate::source::chunk(chunk))
|
||||
.set_name(name)
|
||||
.set_mode(mlua::ChunkMode::Text)
|
||||
.exec()
|
||||
run_source_cached(&self.lua, crate::source::chunk(chunk), name)
|
||||
}
|
||||
|
||||
/// Load and evaluate a Lua chunk, returning its result: for tests and one-shot queries.
|
||||
@@ -1411,3 +1408,125 @@ impl UiScript {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// Where the compiled form of a chunk is kept between sessions. The VM crate has no business
|
||||
/// knowing where a player's files live, so the host implements this and installs it once
|
||||
/// ([`install_chunk_cache`]); with none installed every chunk is parsed, as before.
|
||||
///
|
||||
/// `key` is the cache's own business: it sees the source bytes and the chunk name (which is in the
|
||||
/// dump, so two files with the same text and different names are different entries) and returns
|
||||
/// whatever it wants to look up by. `get` returning stale or foreign bytes is not an error — the
|
||||
/// loader checks the dump's header and the source is parsed instead.
|
||||
pub trait ChunkCache: Send + Sync {
|
||||
fn key(&self, source: &[u8], name: &str) -> String;
|
||||
fn get(&self, key: &str) -> Option<Vec<u8>>;
|
||||
fn put(&self, key: &str, bytecode: &[u8]);
|
||||
/// Drop an entry the loader refused.
|
||||
fn forget(&self, key: &str);
|
||||
}
|
||||
|
||||
static CHUNK_CACHE: std::sync::OnceLock<Box<dyn ChunkCache>> = std::sync::OnceLock::new();
|
||||
|
||||
/// Install the process's chunk cache. Once; a second call is ignored, so a test that installs one
|
||||
/// cannot change another's answers.
|
||||
pub fn install_chunk_cache(cache: Box<dyn ChunkCache>) {
|
||||
let _ = CHUNK_CACHE.set(cache);
|
||||
}
|
||||
|
||||
/// Compile and run `source` under `name`, through the installed cache when there is one.
|
||||
///
|
||||
/// The compile is the expensive half of a UI load and the only half that can be kept: measured
|
||||
/// over the player's enabled addons (1,471 files, 70 MiB of Lua), **parsing is 703 ms and loading
|
||||
/// the same chunks as 5.1 bytecode is 74 ms**. Running them is the other half — the tables they
|
||||
/// build are what an addon is — and is not cacheable.
|
||||
///
|
||||
/// Safe by construction rather than by bookkeeping: an entry that is stale, truncated or from
|
||||
/// another Lua build fails `lua_undump`'s header check and the source is parsed, so the worst a
|
||||
/// bad entry costs is the parse it was meant to save. The dump keeps its debug info, because
|
||||
/// `debugstack` is an addon-facing verb and a stripped chunk would lie about its own lines.
|
||||
pub(crate) fn run_source_cached(lua: &mlua::Lua, source: &[u8], name: &str) -> mlua::Result<()> {
|
||||
let Some(cache) = CHUNK_CACHE.get() else {
|
||||
let t = std::time::Instant::now();
|
||||
let f = lua
|
||||
.load(source)
|
||||
.set_name(name)
|
||||
.set_mode(mlua::ChunkMode::Text)
|
||||
.into_function();
|
||||
chunk_stats().miss(t.elapsed().as_nanos(), 0);
|
||||
return f?.call::<()>(());
|
||||
};
|
||||
let t = std::time::Instant::now();
|
||||
let key = cache.key(source, name);
|
||||
let keyed = t.elapsed().as_nanos();
|
||||
if let Some(code) = cache.get(&key) {
|
||||
let t = std::time::Instant::now();
|
||||
match lua
|
||||
.load(&code[..])
|
||||
.set_name(name)
|
||||
.set_mode(mlua::ChunkMode::Binary)
|
||||
.into_function()
|
||||
{
|
||||
Ok(f) => {
|
||||
chunk_stats().hit(t.elapsed().as_nanos(), keyed);
|
||||
return f.call::<()>(());
|
||||
}
|
||||
// Not the player's error: drop the entry and parse.
|
||||
Err(_) => cache.forget(&key),
|
||||
}
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let f = lua
|
||||
.load(source)
|
||||
.set_name(name)
|
||||
.set_mode(mlua::ChunkMode::Text)
|
||||
.into_function()?;
|
||||
let dump = f.dump(false);
|
||||
chunk_stats().miss(t.elapsed().as_nanos(), keyed);
|
||||
cache.put(&key, &dump);
|
||||
f.call::<()>(())
|
||||
}
|
||||
|
||||
/// What the chunk cache did this process: hits and misses, and the time each spent turning bytes
|
||||
/// into a function. `WOW_LUA_CACHE=0` and a run with the folder deleted are the two controls.
|
||||
#[derive(Default)]
|
||||
pub struct ChunkStats {
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
pub hit_ns: u128,
|
||||
pub miss_ns: u128,
|
||||
pub key_ns: u128,
|
||||
}
|
||||
|
||||
impl ChunkStats {
|
||||
fn hit(&mut self, ns: u128, key_ns: u128) {
|
||||
self.hits += 1;
|
||||
self.hit_ns += ns;
|
||||
self.key_ns += key_ns;
|
||||
}
|
||||
fn miss(&mut self, ns: u128, key_ns: u128) {
|
||||
self.misses += 1;
|
||||
self.miss_ns += ns;
|
||||
self.key_ns += key_ns;
|
||||
}
|
||||
}
|
||||
|
||||
fn chunk_stats() -> std::sync::MutexGuard<'static, ChunkStats> {
|
||||
static STATS: std::sync::OnceLock<std::sync::Mutex<ChunkStats>> = std::sync::OnceLock::new();
|
||||
STATS
|
||||
.get_or_init(|| std::sync::Mutex::new(ChunkStats::default()))
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
}
|
||||
|
||||
/// One line naming what the cache saved, for a harness or a load edge to print.
|
||||
pub fn chunk_cache_line() -> String {
|
||||
let s = chunk_stats();
|
||||
format!(
|
||||
"lua chunks: {} compiled in {:.0} ms, {} loaded from cache in {:.0} ms, keys {:.0} ms",
|
||||
s.misses,
|
||||
s.miss_ns as f64 / 1e6,
|
||||
s.hits,
|
||||
s.hit_ns as f64 / 1e6,
|
||||
s.key_ns as f64 / 1e6,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! The compiled-chunk cache's contract: a cached chunk behaves exactly as a parsed one, and a
|
||||
//! cache that answers rubbish costs a parse rather than a wrong chunk.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::script::{ChunkCache, UiScript};
|
||||
|
||||
/// An in-memory cache that can also be told to lie, since the point of the fallback is what
|
||||
/// happens when an entry is not what it claims.
|
||||
#[derive(Default)]
|
||||
struct Probe {
|
||||
rows: Mutex<HashMap<String, Vec<u8>>>,
|
||||
/// Hand this back instead of the stored entry, if set.
|
||||
poison: Mutex<Option<Vec<u8>>>,
|
||||
hits: Mutex<u32>,
|
||||
forgets: Mutex<u32>,
|
||||
}
|
||||
|
||||
/// FNV-1a over the source, so this probe keys on the bytes like the real cache does: it is
|
||||
/// installed process-wide for the whole test binary, and a key that collided would hand another
|
||||
/// test's chunk the wrong code.
|
||||
fn fnv(bytes: &[u8]) -> u64 {
|
||||
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, &b| {
|
||||
(h ^ u64::from(b)).wrapping_mul(0x1000_0000_01b3)
|
||||
})
|
||||
}
|
||||
|
||||
impl ChunkCache for Probe {
|
||||
fn key(&self, source: &[u8], name: &str) -> String {
|
||||
format!("{name}:{:016x}", fnv(source))
|
||||
}
|
||||
fn get(&self, key: &str) -> Option<Vec<u8>> {
|
||||
if let Some(bad) = self.poison.lock().unwrap().clone() {
|
||||
return Some(bad);
|
||||
}
|
||||
let hit = self.rows.lock().unwrap().get(key).cloned();
|
||||
if hit.is_some() {
|
||||
*self.hits.lock().unwrap() += 1;
|
||||
}
|
||||
hit
|
||||
}
|
||||
fn put(&self, key: &str, bytecode: &[u8]) {
|
||||
self.rows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(key.to_string(), bytecode.to_vec());
|
||||
}
|
||||
fn forget(&self, _key: &str) {
|
||||
*self.forgets.lock().unwrap() += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// One test, because the cache is installed process-wide and installed once: a second test could
|
||||
/// not install its own, and would be reading this one's.
|
||||
#[test]
|
||||
fn a_cached_chunk_runs_the_same_code_keeps_its_line_numbers_and_survives_a_bad_entry() {
|
||||
let probe: &'static Probe = Box::leak(Box::new(Probe::default()));
|
||||
crate::script::install_chunk_cache(Box::new(ProbeRef(probe)));
|
||||
|
||||
let s = UiScript::new().unwrap();
|
||||
let src = "CacheProbe = (CacheProbe or 0) + 1\n\
|
||||
function CacheProbeRaise()\n\
|
||||
\x20 error(\"from the cached chunk\")\n\
|
||||
end\n";
|
||||
|
||||
// First run: a miss that fills the cache.
|
||||
s.run_chunk_named(src.as_bytes(), "@Interface\\AddOns\\Probe\\Probe.lua")
|
||||
.expect("the chunk runs");
|
||||
assert_eq!(s.eval::<i64>("return CacheProbe").unwrap(), 1);
|
||||
let after_first = *probe.hits.lock().unwrap();
|
||||
|
||||
// Second run of the same bytes: served from the cache, and it does the same thing.
|
||||
s.run_chunk_named(src.as_bytes(), "@Interface\\AddOns\\Probe\\Probe.lua")
|
||||
.expect("the cached chunk runs");
|
||||
assert_eq!(s.eval::<i64>("return CacheProbe").unwrap(), 2);
|
||||
assert!(
|
||||
*probe.hits.lock().unwrap() > after_first,
|
||||
"the second run must come from the cache, or this test proves nothing"
|
||||
);
|
||||
|
||||
// The traceback still names the file and the line: the dump keeps its debug info, because
|
||||
// `debugstack` is an addon-facing verb.
|
||||
let err = s.eval::<()>("CacheProbeRaise()").unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("Probe.lua") && err.contains(":3:"),
|
||||
"a cached chunk must raise with its own file and line: {err}"
|
||||
);
|
||||
|
||||
// An entry that is not loadable bytecode is not the player's problem: it is dropped and the
|
||||
// source is parsed instead.
|
||||
*probe.poison.lock().unwrap() = Some(b"\x1bLua this is not a dump".to_vec());
|
||||
s.run_chunk_named(src.as_bytes(), "@Interface\\AddOns\\Probe\\Probe.lua")
|
||||
.expect("a rubbish entry falls back to the source");
|
||||
assert_eq!(s.eval::<i64>("return CacheProbe").unwrap(), 3);
|
||||
assert!(
|
||||
*probe.forgets.lock().unwrap() >= 1,
|
||||
"and the entry the loader refused is dropped"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `'static` borrow of the probe, so the installed box and the test share one.
|
||||
struct ProbeRef(&'static Probe);
|
||||
|
||||
impl ChunkCache for ProbeRef {
|
||||
fn key(&self, source: &[u8], name: &str) -> String {
|
||||
self.0.key(source, name)
|
||||
}
|
||||
fn get(&self, key: &str) -> Option<Vec<u8>> {
|
||||
self.0.get(key)
|
||||
}
|
||||
fn put(&self, key: &str, bytecode: &[u8]) {
|
||||
self.0.put(key, bytecode);
|
||||
}
|
||||
fn forget(&self, key: &str) {
|
||||
self.0.forget(key);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ mod button;
|
||||
mod cast_info;
|
||||
mod caught_raise_cost;
|
||||
mod channel;
|
||||
mod chunk_cache;
|
||||
mod common;
|
||||
mod cooldown;
|
||||
mod create_frame_template;
|
||||
|
||||
Reference in New Issue
Block a user