wandres.dev
ERRORES · pcall y nada más

pcall y xpcall: el manejador corre antes de desenrollar

La diferencia entre ambas funciones no es la comodidad de pasar un manejador, sino el instante en que ese manejador se ejecuta: con la pila todavía intacta. De ahí sale el único rastro de pila útil, y de ahí también el coste real de llamar en modo protegido.

⏱ 19 min

Casi toda la documentación presenta xpcall como un pcall al que además le pasas una función para tratar el error, como si la diferencia fuera de ergonomía. No lo es. La diferencia es temporal: el manejador de xpcall se ejecuta en el instante del fallo, con todos los marcos de la pila todavía presentes, mientras que pcall te devuelve el control cuando esa pila ya se ha deshecho y no queda de ella más que el valor lanzado. Esa ventana de unos pocos microsegundos es la única oportunidad de capturar un rastro de pila, de inspeccionar variables locales del marco que falló o de decidir en función de dónde estaba el programa. Quien no entiende esto acaba escribiendo un rastro de pila desde fuera de pcall y preguntándose por qué solo muestra dos líneas inútiles.

🎯 Al terminar esta lección sabrás
  • Situar el instante exacto en que corre el manejador de xpcall respecto al desenrollado de la pila.
  • Obtener un rastro de pila real y explicar por qué el mismo código fuera del manejador no sirve.
  • Recoger correctamente un número arbitrario de valores de retorno de una llamada protegida.
  • Estimar el coste de una llamada protegida y decidir dónde colocar la barrera en consecuencia.

El instante del manejador

Cuando error se dispara dentro de una llamada protegida, la máquina virtual hace dos cosas en orden. Primero, si la barrera es un xpcall, invoca al manejador pasándole el valor del error, con la pila del fallo todavía viva encima. Después deshace esa pila hasta la barrera y devuelve el control, usando como segundo resultado lo que el manejador haya devuelto.

local function nivel3() error("fallo profundo") end
local function nivel2() nivel3() end
local function nivel1() nivel2() end

local ok, rastro = xpcall(nivel1, debug.traceback)
print(rastro)
-- archivo.lua:1: fallo profundo
-- stack traceback:
--   [C]: in function 'error'
--   archivo.lua:1: in upvalue 'nivel3'
--   archivo.lua:2: in upvalue 'nivel2'
--   archivo.lua:3: in function <archivo.lua:3>
--   [C]: in function 'xpcall'
--   ...

Compara con el intento equivalente y erróneo que casi todo el mundo escribe una vez:

local ok, err = pcall(nivel1)
if not ok then
  print(debug.traceback(err))   -- el rastro es el de AQUI, no el del fallo
end

En el segundo caso, los marcos de las tres funciones ya no existen. El rastro que obtienes describe el lugar donde estás preguntando, no el lugar donde ocurrió el problema, y por tanto no contiene ninguna información que no tuvieras ya.

flowchart TD
A[error se dispara en el marco profundo] --> B[La pila sigue completa]
B --> C[Con xpcall se llama al manejador aqui]
C --> D[El manejador ve todos los marcos y puede leerlos]
D --> E[Se desenrolla la pila hasta la barrera]
B --> F[Con pcall no se llama a nadie]
F --> E
E --> G[Se devuelve falso y el valor o lo que devolvio el manejador]

Qué puede y qué no puede hacer el manejador

El manejador recibe un único argumento —el valor del error— y lo que devuelve se convierte en el segundo resultado de xpcall. Dentro de él tienes acceso a la biblioteca de depuración operando sobre la pila del fallo: puedes pedir el rastro, recorrer los marcos con la función de información de pila, o leer las variables locales del marco que reventó.

local function manejador(err)
  local info = debug.getinfo(2, "Sl")     -- marco donde ocurrio el error
  return {
    mensaje = err,
    fuente  = info and info.short_src,
    linea   = info and info.currentline,
    rastro  = debug.traceback("", 2),
  }
end

local ok, detalle = xpcall(procesar, manejador, entrada)

Ese último argumento es importante: desde Lua 5.2, xpcall acepta argumentos extra que se pasan a la función llamada, igual que hace pcall. Antes de esa versión había que envolver la llamada en un closure, lo que suponía asignar un objeto nuevo en cada invocación.

Hay tres cosas que el manejador no debe hacer. La primera es trabajo pesado: se ejecuta en un estado del intérprete que puede ser precario, y el caso extremo es un desbordamiento de pila, donde el margen para llamar a más funciones es mínimo. La segunda es fallar: un error lanzado dentro del manejador no se maneja llamándolo otra vez, sino que aborta la secuencia con un estado especial y un mensaje fijo sobre error durante el manejo de errores, perdiendo por completo el diagnóstico original. La tercera es retener referencias a objetos de la pila del fallo más allá de su vida útil, porque prolongan artificialmente la vida de estructuras que deberían morir con el desenrollado.

⚠️
El rastro debe convertirse a texto dentro del manejador

No guardes el nivel de pila para consultarlo después ni devuelvas un objeto que prometa producir el rastro más tarde. Cuando el manejador retorna, la pila desaparece. Todo lo que quieras saber del lugar del fallo tiene que estar ya materializado en forma de cadenas o de valores copiados antes de ese retorno.

Recoger los resultados sin perder ninguno

La forma habitual de leer el retorno de una llamada protegida asume que sabes cuántos valores devuelve la función. Cuando no lo sabes —porque envuelves código ajeno, o porque la función tiene retornos variables— hay que capturar la tupla completa.

local resultados = table.pack(pcall(funcionDesconocida, a, b))
if not resultados[1] then
  return nil, resultados[2]
end
return table.unpack(resultados, 2, resultados.n)

El campo de longitud que produce esa función de empaquetado es imprescindible aquí, porque un retorno legítimo puede contener valores nulos intercalados y el operador de longitud no los contaría. Este patrón es la base de cualquier envoltorio genérico, y su coste es una tabla por llamada, lo que lo descarta para rutas calientes pero lo hace perfectamente aceptable en fronteras de módulo.

Cuando el número de retornos sí se conoce, hay una forma más barata que evita la tabla y que conviene tener a mano:

local ok, a, b, c = pcall(fn, x)
if not ok then return nil, a end   -- en el caso de fallo, a es el error

La asimetría de esa línea —la misma variable contiene un resultado o un error según el booleano anterior— es fea pero idiomática, y es la razón de que la convención de nombrar las variables de retorno de una llamada protegida sea distinta de la habitual: mucho código las llama simplemente por su posición precisamente para no mentir sobre su contenido.

📝
Ambas funciones capturan más de lo que crees

La barrera no solo atrapa los errores que lanza tu código con error. Atrapa también los del propio intérprete: indexar un valor nulo, llamar a algo que no es invocable, aritmética sobre tipos incompatibles, desbordamiento de la pila de llamadas y agotamiento de memoria. Eso convierte una llamada protegida en una red muy amplia, y es exactamente la razón de que descartarla sin examinar el resultado sea tan peligroso: puedes estar ocultando un defecto de programación creyendo que ocultas un fallo previsto.

El coste de llamar en modo protegido

Instalar una barrera no es gratis. La máquina virtual guarda un punto de recuperación, registra el nivel de pila actual y marca el límite de la llamada protegida. En términos de orden de magnitud, una llamada protegida cuesta entre dos y cuatro veces lo que una llamada ordinaria a la misma función, dependiendo de la versión, de la plataforma y de si el intérprete se compiló con manejo de excepciones del anfitrión o con saltos no locales.

⚠️

El closure anónimo es peor que la barrera

Escribir la llamada protegida con una función anónima que capture variables asigna un closure nuevo en cada invocación. Ese coste suele superar al de la propia barrera. Pasa la función y sus argumentos por separado.

📐

Coloca la barrera por unidad de trabajo

Una barrera por petición atendida, por fotograma, por fichero procesado o por comando del usuario. Nunca una por elemento de una colección de un millón.

🧵

Se puede suspender a través de ella

Desde Lua 5.2 ambas funciones son continuables, de modo que una corrutina puede suspenderse desde dentro de una llamada protegida. En Lua 5.1 eso era un error de frontera de C.

🎯

Elige según lo que vayas a hacer con el fallo

Si solo vas a decidir entre dos caminos, la barrera simple basta y es más barata. Si el fallo va a un registro que alguien leerá a las tres de la mañana, necesitas el manejador y el rastro.

-- Coste innecesario: un closure nuevo en cada vuelta
for _, tarea in ipairs(cola) do
  local ok = pcall(function() ejecutar(tarea, contexto) end)
end

-- Sin asignaciones: la funcion y sus argumentos van por separado
for _, tarea in ipairs(cola) do
  local ok = pcall(ejecutar, tarea, contexto)
end

Una consideración adicional afecta a las optimizaciones. El cuerpo de la función llamada conserva sus llamadas de cola con normalidad, pero la barrera en sí introduce un marco de C que aparece en los rastros y que corta la cadena de marcos de Lua. En programas con muchas capas protegidas anidadas, esa acumulación de marcos de C consume el límite de llamadas anidadas del anfitrión mucho antes que el límite de pila de Lua.

El rastro de pila no es información sobre el error, es información sobre el camino

Hay una asimetría profunda entre el valor del error y el rastro de la pila, y confundirlos lleva a diseños que registran mucho y explican poco. El valor del error responde a qué ha fallado: un fichero que no existe, un número fuera de rango, un campo ausente. Esa respuesta es local, cabe en una línea, y muchas veces el usuario del programa puede actuar sobre ella sin saber nada más. El rastro responde a una pregunta distinta y mucho más cara de obtener: cómo hemos llegado hasta aquí. Un mismo error de campo ausente puede provenir de una configuración mal escrita, de un mensaje de red truncado o de un caché que devolvió una entrada a medio construir, y el valor del error es idéntico en los tres casos mientras que el camino los distingue sin ambigüedad. Por eso Lua paga el precio de tener dos funciones casi iguales en lugar de una: porque el camino solo existe durante el instante que separa el lanzamiento del desenrollado, y capturarlo obliga a ejecutar código justo ahí, en el peor momento posible, cuando el estado del intérprete es más frágil. La consecuencia práctica es una regla que conviene grabar: en las fronteras interiores de tu programa, donde el fallo se traduce a una decisión, usa la barrera simple y ahórrate el coste; en la frontera exterior, la que separa tu proceso del mundo y donde el fallo se convierte en una línea de registro que alguien leerá sin poder reproducir el estado, usa siempre el manejador. La diferencia entre un incidente de veinte minutos y uno de dos días casi nunca está en el mensaje: está en si alguien se acordó de capturar el camino.

⚔️ Captura el camino, no solo el destino
  1. Reproduce el ejemplo de las tres funciones anidadas y compara el rastro obtenido dentro del manejador con el obtenido después de la barrera simple.
  2. Escribe un manejador que devuelva una tabla con mensaje, fichero, línea y rastro, y verifica que los datos corresponden al marco profundo.
  3. Provoca un error dentro del propio manejador y observa qué recibe la llamada exterior. Anota qué información has perdido.
  4. Envuelve una función de retornos variables con el patrón de empaquetado y comprueba que sobreviven los valores nulos intermedios.
  5. Mide un millón de llamadas directas frente a un millón de llamadas protegidas, y después frente a un millón envueltas en una función anónima. Ordena los tres costes.