Documentación/El runtime de Lua embebido
El runtime de Lua embebido
El crate dbflux_lua es el runtime de Lua 5.4 sandboxed de DBFlux para los hooks de conexión. Este documento describe la arquitectura del crate, la API de Lua expuesta a los scripts de hooks, el modelo de sandbox y timeout, y cómo el runtime se integra en la aplicación.
Qué hace este crate
dbflux_lua permite a los usuarios escribir scripts de Lua que se ejecutan durante los eventos del ciclo de vida de la conexión (pre-connect, post-connect, pre-disconnect, post-disconnect). Los hooks son de propósito general: pueden impulsar flujos de login SSO, configuración de entorno, audit logging, o disparar herramientas externas antes/después de que se abra una conexión.
El crate expone exactamente un tipo público: LuaExecutor. Todo lo demás — la fábrica de VMs, los módulos de API, el estado compartido — es interno al crate. Desde afuera, llamas a executor.execute_hook(hook, context, cancel_token, parent_cancel_token, output, detached) y obtienes un HookResult. El argumento final detached: Option<&DetachedProcessSender> permite que el executor entregue procesos detached de larga duración de vuelta al caller.
Visión general de la arquitectura
El principio de diseño clave: se crea una VM de Lua nueva para cada ejecución de hook. Sin pooling de VMs, sin estado que se filtre entre ejecuciones. Esto hace que el sandbox sea trivialmente seguro — incluso si un script de alguna forma corrompe el estado de la VM, esta se descarta después de la ejecución.
Dependencias
| Dependencia | Versión | Propósito |
|---|---|---|
mlua | 0.10 | Bindings de Lua 5.4. Features: lua54, send (hace Lua: Send), vendored (compila Lua desde source) |
dbflux_core | workspace | Traits (HookExecutor), tipos (ConnectionHook, HookContext, etc.) |
log | 0.4 | Logging desde el lado de Rust para callbacks de Lua |
El feature vendored es importante — significa que no se requiere una instalación de Lua a nivel de sistema. El intérprete de Lua 5.4 se compila desde código fuente en C y se enlaza estáticamente. Esto elimina una dependencia de deployment pero agrega ~200KB al binario.
El sandbox
Qué se carga
Solo cuatro librerías estándar de Lua:
let stdlib = StdLib::TABLE | StdLib::STRING | StdLib::MATH | StdLib::UTF8;
let lua = Lua::new_with(stdlib, LuaOptions::default())?;
Esto le da a los scripts acceso a:
- table:
table.insert,table.remove,table.sort,table.concat,table.pack,table.unpack - string:
string.format,string.find,string.gsub,string.sub,string.len,string.match,string.rep, pattern matching - math:
math.floor,math.ceil,math.random,math.sqrt,math.abs,math.max,math.min,math.pi - utf8:
utf8.char,utf8.codepoint,utf8.len
Más los built-ins de Lua que no requieren cargar librerías: type(), tostring(), tonumber(), pairs(), ipairs(), next(), select(), pcall(), xpcall(), error(), setmetatable(), getmetatable(), rawget(), rawset(), rawequal(), rawlen(). Los closures, variables locales, metatables, todo el control de flujo — todo lo que hace que Lua sea Lua funciona sin problema.
Qué está bloqueado
| Librería | Por qué está bloqueada |
|---|---|
io | Lectura/escritura de archivos. No se puede permitir que los hooks lean archivos arbitrarios o escriban en disco. |
os | Llamadas al sistema: os.execute() sería un escape completo a shell, os.remove() puede borrar archivos. Incluso os.getenv() se reemplaza con el dbflux.env.get() gated. |
debug | debug.sethook() podría interferir con el interrupt basado en conteo de instrucciones. debug.getlocal() y debug.getinfo() podrían inspeccionar estado interno. |
package | require(), dofile(), loadfile() permitirían cargar código arbitrario desde disco. |
coroutine | No es peligrosa en sí misma, pero agrega complejidad al modelo de timeout/cancelación (las coroutines pueden hacer yield más allá del instruction hook). |
El sandbox es “allowlist, no blocklist”. Solo existen las cuatro librerías cargadas explícitamente más las funciones de API registradas. Si no está en la lista de arriba, no existe en la VM de Lua.
Límite de memoria
Cada VM se crea con un cap de memoria forzado de 16 MiB (lua.set_memory_limit(16 * 1024 * 1024) en engine.rs). Un script que asigna más allá de este límite falla con un error de memoria en lugar de que se le permita agotar la memoria del host.
La API de Lua
hook.* — Siempre disponible
Esta es la API central de control de flujo. Cada script de hook de Lua comunica su resultado a través de estas funciones.
-- Read the current phase
local phase = hook.phase -- "pre_connect", "post_connect", ...
-- Signal outcomes
hook.ok() -- success (this is the default if nothing is called)
hook.warn("msg") -- success, but surface a warning to the user
hook.fail("msg") -- failure, abort the connection flow
El outcome es una máquina de estados simple con tres estados: Ok, Warn(msg), Fail(msg). Las llamadas múltiples se sobrescriben — solo importa la última llamada antes de que el script termine. Si el script se completa sin llamar a ninguna de estas, el outcome por defecto es Ok.
El outcome se mapea a HookResult así:
| Outcome | exit_code | stderr | warnings |
|---|---|---|---|
Ok | 0 | vacío | [] |
Warn(msg) | 0 | vacío | [msg] |
Fail(msg) | 1 | msg | [] |
connection.* — Metadata de conexión
Gated por capabilities.connection_metadata (por defecto: true).
connection.profile_id -- "550e8400-e29b-41d4-a716-446655440000"
connection.profile_name -- "Production DB"
connection.db_kind -- "Postgres", "SQLite", "MongoDB", "Redis", "MySQL"
connection.host -- "db.example.com" or nil (SQLite has no host)
connection.port -- 5432 or nil
connection.database -- "myapp" or nil
Todos los valores son snapshots estáticos tomados en el momento de crear la VM. El script no puede cambiarlos. Esto es intencional — los hooks observan la conexión, no la configuran.
dbflux.log.* — Logging
Gated por capabilities.logging (por defecto: true).
dbflux.log.info("Starting SSO flow")
dbflux.log.warn("Token expires in 5 minutes")
dbflux.log.error("AWS CLI not found")
Cada llamada hace dos cosas:
- Agrega
[LEVEL] messagea un buffer de log interno (que se convierte en elstdoutdelHookResult) - Reenvía al crate
logde Rust en el nivel correspondiente, con el prefijo[lua]
Cuando el caller provee un canal de salida, la misma línea de log también se transmite inmediatamente a la UI. El buffer de log sigue siendo la salida durable primaria para el HookResult final.
dbflux.env.* — Variables de entorno
Gated por capabilities.env_read (por defecto: true).
local home = dbflux.env.get("HOME") -- "/home/user" or nil
local profile = dbflux.env.get("AWS_PROFILE") -- "production" or nil
if not dbflux.env.get("DATABASE_URL") then
hook.fail("DATABASE_URL is not set")
end
Solo lectura. Sin set() ni unset() — los hooks no pueden modificar el entorno. Esto reemplaza os.getenv(), que requeriría cargar la librería insegura os.
dbflux.process.* — Ejecución de procesos controlada
Gated por capabilities.process_run (por defecto: false). Debe habilitarse explícitamente.
Incluso cuando está habilitada, la API de procesos tiene doble gate mediante un sistema de allowlist. No puedes ejecutar programas arbitrarios — solo herramientas específicas de categorías predefinidas.
local result = dbflux.process.run({
program = "aws",
allowlist = "aws_cli",
args = { "sso", "login", "--profile", "prod" },
timeout_ms = 120000,
cwd = "/home/user",
stream = true,
})
if not result.ok then
hook.fail("AWS SSO login failed: " .. result.stderr)
end
dbflux.log.info("AWS SSO login succeeded")
hook.ok()
Opciones de entrada:
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
program | string | sí | Nombre de comando plano (sin separadores de ruta) |
allowlist | string | sí | Debe coincidir con un nombre de allowlist conocido |
args | string[] | no | Argumentos del comando |
timeout_ms | integer | no* | Timeout por proceso (ms). El timeout a nivel de hook igual aplica por encima. |
cwd | string | no | Directorio de trabajo |
stream | boolean | no | Transmite stdout/stderr al caller mientras el proceso sigue en ejecución |
detached | boolean | no | Entrega el proceso spawneado de vuelta al caller y retorna inmediatamente, en lugar de esperar a que termine |
* Para un run no detached, timeout_ms es efectivamente requerido cuando no hay timeout a nivel de hook: una llamada sin timeout_ms ni timeout a nivel de hook falla con el error de runtime "dbflux.process.run requires a timeout_ms when no hook-level timeout is set". Un run detached está exento de esta restricción.
Valor de retorno:
| Campo | Tipo | Descripción |
|---|---|---|
ok | boolean | true si el proceso fue detached, o si el exit code es 0 y no hubo timeout |
detached | boolean | true si el proceso se entregó como detached (en cuyo caso los campos de output/exit de abajo quedan vacíos/nil) |
exit_code | integer/nil | Exit code del proceso |
stdout | string | stdout capturado |
stderr | string | stderr capturado |
timed_out | boolean | true si se disparó el timeout por proceso |
Allowlists disponibles:
| Allowlist | Programas permitidos |
|---|---|
aws_cli | aws, aws.exe |
python_cli | python, python.exe, python3, python3.exe |
ssh_cli | ssh, ssh.exe |
cloudflared | cloudflared, cloudflared.exe |
gcloud_cli | gcloud, gcloud.cmd, gcloud.exe |
az_cli | az, az.cmd, az.exe |
program debe ser un nombre de comando plano. Los nombres calificados con ruta se rechazan antes de verificar la allowlist: cualquier programa que contenga un / o \, que esté compuesto de múltiples componentes de ruta, o que empiece con ~ falla con el error de runtime "Program '...' must be a bare command name (no path separators)". Así que program = "/usr/local/bin/aws" se rechaza directamente — pasa program = "aws" en su lugar y deja que se resuelva vía PATH. La comparación del nombre plano contra la allowlist no distingue mayúsculas/minúsculas.
Este diseño responde a un caso de uso específico: hooks que necesitan disparar herramientas de CLI en la nube (login SSO, configuración de túnel, obtención de secrets) sin abrir un escape completo a shell. La allowlist es una guardia de ergonomía y footgun — previene typos y ejecución accidental de programas inesperados. No es un límite de aislamiento de seguridad: un usuario que controla PATH aún puede sustituir un binario diferente bajo el mismo nombre. Las allowlists hardcodeadas se pueden extender más adelante a medida que surjan nuevos casos de uso.
Timeout y cancelación
Hay tres capas de interrupción, y entender cómo interactúan es importante.
Capa 1: instruction hook de Lua
lua.set_hook(
HookTriggers::new().every_nth_instruction(1_000),
move |_lua, _debug| { ... }
);
Cada 1.000 instrucciones de Lua, el hook se dispara y verifica:
- ¿Está seteado el cancel token? →
RuntimeError("Lua hook cancelled") - ¿Se agotó el timeout? →
RuntimeError("Lua hook timed out")
Esto captura loops infinitos, cómputos descontrolados y código Lua puro de larga duración. El intervalo de 1.000 instrucciones es un balance entre responsividad (verificar seguido) y performance (verificar no es gratis).
Limitación: este hook solo se dispara para instrucciones de bytecode de Lua. Si el script llama a una función bloqueante de Rust (como dbflux.process.run), el instruction hook no se dispara hasta que esa función retorna. Por eso…
Capa 2: Process Executor compartido
Dentro de dbflux.process.run, la ejecución de procesos se delega al helper compartido dbflux_core::execute_streaming_process(). Ese helper:
- crea threads lectores para stdout y stderr
- empuja chunks de output a través de un canal
- verifica cancel tokens y timeouts en un intervalo corto
- mata al proceso hijo en caso de cancelación o timeout
- retorna una tabla de resultado normal para el timeout por proceso, o un error de runtime de Lua para cancelación/timeout a nivel de hook
Esto mantiene alineados los hooks de Lua y los hooks de script que no son Lua. Se usa el mismo camino de ejecución de procesos de bajo nivel para subprocesos disparados desde Bash, Python y Lua.
Capa 3: Parent cancel token
El flujo de conexión pasa un parent cancel token que cancela todos los hooks cuando se aborta la operación general de connect/disconnect. Tanto el instruction hook como el process executor compartido verifican este token junto con el específico del hook.
Jerarquía de timeouts
Hook-level timeout (e.g., 30s)
└── Process-level timeout (e.g., 120s for SSO login)
└── Actually, process timeout < hook timeout to be useful
Si el timeout a nivel de hook se dispara mientras un proceso está corriendo, el proceso se mata y todo el hook aborta con un error de timeout de Lua, que LuaExecutor convierte en HookResult { timed_out: true }.
Si el timeout a nivel de proceso se dispara, solo ese proceso se mata. El script sigue ejecutándose y puede manejar el timeout con gracia:
local result = dbflux.process.run({ ..., timeout_ms = 5000 })
if result.timed_out then
dbflux.log.warn("Process timed out, falling back to cached credentials")
end
Manejo de errores
Cómo fluyen los errores
Script execution
│
├─ Completes normally → outcome (Ok/Warn/Fail) determines HookResult
│
├─ "Lua hook cancelled" → Err(String) returned to caller
│ (the ONLY case that returns Err)
│
├─ "Lua hook timed out" → Ok(HookResult { timed_out: true })
│
└─ Any other Lua error → Ok(HookResult { exit_code: 1, stderr: error_msg })
La cancelación es el único caso que retorna Err desde execute_hook. Los timeouts y errores de runtime son outcomes normales de “el hook falló” y se capturan en HookResult.
Detección de errores basada en sentinelas
mlua envuelve los errores en capas de CallbackError y WithContext. Para detectar cancelación vs. timeout, el código usa una función recursiva error_has_message que desenvuelve estas capas buscando las cadenas sentinela exactas "Lua hook cancelled" y "Lua hook timed out".
Esta es una solución pragmática. Un approach más limpio sería usar tipos de error personalizados, pero el modelo de errores de mlua hace eso poco práctico sin luchar contra la librería. El approach de sentinelas funciona de forma confiable porque estas cadenas exactas solo son producidas por nuestro instruction hook y el camino de ejecución de procesos compartido.
LuaCapabilities
Definido en dbflux_core::connection::hook:
pub struct LuaCapabilities {
pub logging: bool, // default: true
pub env_read: bool, // default: true
pub connection_metadata: bool, // default: true
pub process_run: bool, // default: false
}
Estos se configuran por hook en la UI de Settings. Los defaults son deliberadamente conservadores — process_run es la única capability peligrosa, y está deshabilitada por defecto.
Las verificaciones de capability ocurren en el momento de crear la VM, no en el momento de la llamada. Si logging es false, la tabla dbflux.log simplemente no existe en la VM. No hay verificación en runtime; el sandbox es estructural.
Detalles internos de arquitectura
LuaRuntimeState
pub struct LuaRuntimeState {
pub outcome: Arc<Mutex<LuaHookOutcome>>,
pub log_buffer: Arc<Mutex<Vec<String>>>,
pub output: Option<OutputSender>,
pub detached: Option<DetachedProcessSender>,
pub cancel_token: CancelToken,
pub parent_cancel_token: Option<CancelToken>,
pub hook_started_at: Instant,
pub hook_timeout: Option<Duration>,
}
Este es el estado mutable compartido al que acceden tanto los callbacks de Lua como el executor. El patrón Arc<Mutex<...>> es necesario porque los closures de Lua (registrados como funciones de API) capturan Arcs clonados, y el executor lee el estado final después de la ejecución del script.
El sender de output es opcional. Cuando está presente, las llamadas de log de Lua y dbflux.process.run({ stream = true }) reenvían output en vivo a la UI mientras siguen preservando el output buffereado final en HookResult.
El cancel_token y los campos de timing también se comparten con la ejecución de procesos, creando una única vista del contexto de ejecución a través de todas las capas.
LuaVmConfig
LuaEngine::create_vm() toma un struct LuaVmConfig en lugar de una lista larga de argumentos. Agrupa el contexto del hook, la fase, las capabilities, el estado de cancelación, el sender de output opcional y la metadata de timeout necesaria para construir una VM nueva.
LuaVm
pub struct LuaVm {
pub lua: Lua,
pub state: LuaRuntimeState,
}
Agrupa la VM de Lua y el estado compartido para que el executor pueda acceder a ambos. Después de que vm.lua.load(&script).exec() se completa, el executor lee vm.state.log_buffer y vm.state.outcome para construir el HookResult.
El patrón de lazy init de la tabla dbflux
fn ensure_dbflux_table(lua: &Lua) -> LuaResult<Table> {
let globals = lua.globals();
match globals.get::<Table>("dbflux") {
Ok(table) => Ok(table),
Err(_) => {
let table = lua.create_table()?;
globals.set("dbflux", table.clone())?;
Ok(table)
}
}
}
Cada función register_*_api llama a esto para obtener-o-crear el global dbflux. Esto permite que las capabilities se registren de forma independiente sin conocerse entre sí — cada una simplemente agrega su sub-tabla al padre compartido.
Guía de estilo de scripts
Basándose en los casos de test y el diseño de la API, así es la forma idiomática de escribir hooks de Lua:
Hook básico
dbflux.log.info("Pre-connect hook for " .. connection.profile_name)
if connection.db_kind == "Postgres" and hook.phase == "pre_connect" then
local db_url = dbflux.env.get("DATABASE_URL")
if not db_url then
hook.fail("DATABASE_URL environment variable is not set")
return
end
end
hook.ok()
Hook de login SSO
local result = dbflux.process.run({
program = "aws",
allowlist = "aws_cli",
args = { "sso", "login", "--profile", connection.profile_name },
timeout_ms = 120000,
})
if not result.ok then
hook.fail("AWS SSO login failed: " .. result.stderr)
return
end
dbflux.log.info("AWS SSO login completed successfully")
hook.ok()
Condicional por fase
if hook.phase == "pre_connect" then
dbflux.log.info("Establishing tunnel...")
-- setup logic
elseif hook.phase == "post_disconnect" then
dbflux.log.info("Cleaning up...")
-- teardown logic
end
Patrón de manejo de errores
-- Use pcall for operations that might fail
local ok, err = pcall(function()
-- risky operations here
end)
if not ok then
hook.fail("Unexpected error: " .. tostring(err))
return
end
Convenciones
- Usa
returndespués dehook.fail()— el script sigue ejecutándose después dehook.fail(), que simplemente setea un flag. Si no haces return, código posterior podría llamar ahook.ok()y sobrescribir el fallo. Gana la última llamada. - Loguea con generosidad — el output de
dbflux.log.info()aparece en el result panel. Es la única forma de comunicar progreso y depurar problemas. - Verifica
result.ok, noresult.exit_code— el campookconsidera tanto el exit code como el timeout.exit_codepuede sernilen casos límite. - No dependas de que
hook.phaseesté ausente en el editor — cuando se ejecuta un script desde el botón Run del editor de código (no como parte de un flujo de conexión), la fase por defecto es"pre_connect". La lógica dependiente de fase debe manejar esto con gracia.
Limitaciones
Sin async
Todo es síncrono y bloqueante. La VM de Lua corre en un thread en background, y dbflux.process.run bloquea ese thread hasta que el process executor compartido termina. Para la mayoría de los casos de uso de hooks (llamadas a herramientas CLI, verificaciones de entorno), esto está bien. Pero no puedes hacer requests HTTP asíncronos ni operaciones en paralelo.
Sin acceso a red
No hay cliente HTTP, librería de sockets, ni API de red. La única forma de interactuar con servicios externos es a través de dbflux.process.run con una herramienta CLI en la allowlist. Esto es intencional — un cliente HTTP sandboxed necesitaría filtrado cuidadoso de URLs y expandiría significativamente la superficie de ataque.
Sin I/O de archivos
Sin io.open, sin os.rename, sin lectura o escritura directa de archivos desde Lua mismo. Si necesitas datos del mundo exterior, tienes que pasar por un proceso permitido en la allowlist como Python o un CLI de nube.
Sin estado persistente
Cada ejecución de hook crea una VM nueva. No hay forma de guardar estado entre invocaciones. Si necesitas estado persistente, escríbelo a un archivo a través de un proceso externo y léelo de vuelta en la siguiente invocación.
Sin require()
La librería package no se carga, así que require() no existe. No puedes dividir código Lua en múltiples archivos ni usar librerías de Lua de terceros. Toda la lógica del hook debe ser autocontenida en un único script.
Sin os.time() ni os.clock()
La librería os está bloqueada por completo. Si necesitas timing, tendrás que medirlo externamente. Esto también significa que math.randomseed(os.time()) no funciona — math.random() usa el seed que sea que mlua provea (que depende de la implementación).
Allowlists limitadas
Las allowlists de procesos están hardcodeadas. Agregar una herramienta nueva requiere un cambio de código, un rebuild y un release nuevo. No hay (por ahora) un mecanismo de allowlist configurable por el usuario. Las seis allowlists actuales cubren los casos de uso más comunes (CLIs de nube, SSH, scripts de Python).
Sin syntax highlighting de Lua en el editor
gpui-component (v0.5.0) no incluye una grammar tree-sitter-lua. Al editar scripts de Lua en el editor de código, no hay syntax highlighting. editor_mode() retorna "lua", que cae con gracia a plaintext. Los scripts de Python y Bash tienen highlighting completo.
Memoria acotada
Cada VM tiene un cap de 16 MiB de memoria asignada por Lua. Los scripts que intentan construir estructuras de datos muy grandes en memoria van a golpear este techo y fallar. Esta es una guardia de sandbox, no un ajuste configurable por hook.
El output está impulsado por API
La forma soportada de comunicar progreso y diagnósticos es dbflux.log.*. Ese output se buffea en el HookResult final, y también puede transmitirse en vivo cuando el caller lo solicita.
Cómo se integra en la aplicación
Feature flag
La dependencia opcional dbflux_lua y su feature lua viven en el app crate, crates/dbflux_app/Cargo.toml:
dbflux_lua = { workspace = true, optional = true }
# ...
[features]
lua = ["dbflux_lua"]
El binary crate, crates/dbflux/Cargo.toml, no tiene dependencia directa de dbflux_lua. Su feature lua simplemente reenvía a los crates de app y UI, y forma parte del set por defecto:
[features]
lua = ["dbflux_app/lua", "dbflux_ui/lua"]
default = ["sqlite", "postgres", "mysql", "mongodb", "redis", "dynamodb", "cloudwatch", "influxdb", "mssql", "lua", "aws", "mcp"]
El feature lua está en el set por defecto, así que siempre está habilitado en builds normales. Se puede deshabilitar para builds que no necesitan Lua (reduce el tamaño del binario en ~200KB).
CompositeExecutor
crates/dbflux_app/src/hook_executor.rs define el router (re-exportado desde crates/dbflux_app/src/lib.rs):
#[derive(Clone)]
pub struct CompositeExecutor {
process: ProcessExecutor,
#[cfg(feature = "lua")]
lua: dbflux_lua::LuaExecutor,
}
HookKind::Lua se enruta a LuaExecutor. HookKind::Command y HookKind::Script van a ProcessExecutor. Sin el feature lua, los hooks de Lua retornan un mensaje de error.
Integración con el botón Run
El botón Run del editor de código (execution.rs) usa CompositeExecutor para ejecutar scripts. Para scripts de Lua, crea un ConnectionHook inline a partir del contenido del editor con LuaCapabilities::all_enabled() y un timeout de 30 segundos, pasa un canal de output a execute_hook, y renderiza output en vivo en el results panel mientras el script sigue en ejecución. El stdout final (buffer de log) y el stderr se siguen preservando en el resultado de texto completado.
Testing
Todos los tests están en el crate mismo (no en un directorio tests/ separado). La cobertura actualmente abarca:
executor.rs: outcomes normales, errores de runtime, scripts respaldados por archivo, cancelación, timeouts, gating de capabilities, enforcement de allowlist, y comportamiento de output de procesos transmitidosengine.rs: fase del hook, metadata de conexión, librerías inseguras ocultas, visibilidad opcional de API, y comportamiento de construcción de VMapi/dbflux.rs: validación de opciones de proceso, manejo de timeout de hook expirado antes de spawnear, formateo de eventos de log en vivo, y stdout/stderr parcial transmitido durante cancelación
Ejecutar los tests
cargo test -p dbflux_lua # all tests
cargo test -p dbflux_lua -- timeout # specific test by name
Algunos tests spawnean procesos reales (echo, sleep, python3) y tienen timeouts, así que toman uno o dos segundos. Los tests relacionados con procesos usan cfg!(target_os = "windows") para seleccionar comandos apropiados por plataforma.
Lecciones y trampas
El problema del envoltorio de errores de mlua
mlua envuelve los errores en múltiples capas: CallbackError { cause: WithContext { context: "...", cause: RuntimeError("actual message") } }. Cuando quieres detectar un error específico (como “Lua hook cancelled”), no puedes simplemente hacer match sobre la variante externa — tienes que desenvolver recursivamente. La función error_has_message hace esto, pero es frágil. Si mlua cambia su comportamiento de envoltorio, la detección de sentinelas se rompe en silencio.
Un approach mejor podría ser usar Error::external() de mlua con un tipo de error personalizado que implemente std::error::Error, pero el approach de sentinelas actual se ha mantenido bien a través de las versiones de mlua.
El intervalo de 1.000 instrucciones
El instruction hook se dispara cada 1.000 instrucciones. Esto significa:
- Un loop ajustado que no hace nada toma ~1.000 iteraciones antes de que se dispare la verificación de cancelación
- Para precisión de timeout, 1.000 instrucciones se traducen a aproximadamente microsegundos, así que la precisión del timeout es excelente
- Setearlo demasiado bajo (p. ej., cada instrucción) impacta medible en la performance de scripts computacionales
- Setearlo demasiado alto (p. ej., cada 100.000) hace que la cancelación se sienta lenta
1.000 balancea la responsividad de cancelación contra el overhead por verificación.
La estratificación de timeout en process_run
El timeout de tres capas (instruction hook, process executor compartido, timeout por proceso) puede resultar confuso. La observación clave: el timeout a nivel de proceso es recuperable (el script continúa), el timeout a nivel de hook no lo es (el hook falla). Así que siempre debes setear timeout_ms en las llamadas a dbflux.process.run a algo menor que el timeout del hook, permitiendo que el script maneje el fallo con gracia.
¿Por qué no simplemente permitir os.execute()?
Podría parecer más simple cargar la librería os y dejar que los usuarios corran lo que quieran. El problema es que os.execute() no provee captura de output, sin timeout, sin cancelación, y sin filtrado de programas. La API dbflux.process.run nos da todo esto. La allowlist es el precio de restringir qué programas puede lanzar un hook en una app GUI que ejecuta scripts de usuario. Es una guardia de ergonomía/footgun más que un límite de aislamiento de seguridad — la sustitución vía PATH todavía puede intercambiar el binario detrás de un nombre permitido.
VM nueva por ejecución — costo vs. seguridad
Crear una VM de Lua 5.4 nueva por invocación de hook cuesta ~0.5ms. Para algo que corre como máximo 4 veces por ciclo de vida de conexión, esto es despreciable. El beneficio — aislamiento perfecto entre ejecuciones — vale mucho más que el costo. Un approach de VM pooled ahorraría microsegundos pero introduciría bugs sutiles de filtrado de estado.