Errors
GameLisp's error-handling story is quite minimalist. Errors silently bubble up through the
call-stack; they can be caught by the Rust API or caught within GameLisp code; and uncaught errors
print a stack trace and an error message. Internally, they're implemented using Result
.
(defmacro add-5 (form)
`(+ 5 ~form))
(defn recursive (n)
(cond
((> n 0)
(recursive (- n 1)))
(else
(add-5 'symbol))))
(recursive 2)
#|
stack trace:
glsp::load("example.glsp")
(recursive) at example.glsp:11
(recursive) at example.glsp:7
(recursive) at example.glsp:7
(add-5) at example.glsp:9
expanded to (+) at example.glsp:2
error: non-number passed to a numeric op
|#
You can manually trigger an error by calling bail
or ensure
,
which resemble Rust's panic!()
and assert!()
respectively. They accept any number of arguments
to describe their error message. When two or more error-message arguments are present, those
arguments are converted to a string, as though they had been passed to the str
function.
(bail "expected {expected-type} but received {(type-of arg)}")
(ensure (>= [stats 'level] 10) "level is too low. stats: " (pretty-stats))
ensure
doesn't actually evaluate its error-message arguments unless an error occurs, so it's
safe to use expensive function calls when describing the error.
Error Recovery
In game development, closing down the release-build executable when an error occurs should be the last resort - something only done for unrecoverable errors. For some errors, it's safe to simply log the fact that an error occurred, and then continue executing.
Consider code which loops through each of your game's entities and calls a function to render them to the screen. If you catch any error produced by an entity's rendering function, and then move on to the next entity as normal, it could potentially change a catastrophic bug (the game crashing) into a minor one ("due to a calculation error, the fire elemental's particle effect sometimes disappears").
You can catch errors within GameLisp code using the macros try
and
try-verbose
:
(for entity in draw-list
(match (try (draw-entity entity))
(('ok result)
#n)
(('err payload)
(log-error entity payload))))
try
evaluates its child forms within an implicit do
block. When no error occurs, it returns
the two-element array (ok result)
, where the first element is the symbol ok
, and the second
element is the result of evaluating its last child form.
If an error occurs within the try
form's dynamic scope - including errors generated internally
by GameLisp, a Result::Err
being returned by a Rust function, or even a panic!()
within a
Rust function - the try
macro will catch the error and return (err payload)
, where the first
element is the symbol err
and the second element is a value which represents the error.
When bail
or ensure
are called with a single argument, that argument will not be converted
into a string. This means that payload
can have any type. You could potentially use it to
set up a more structured and formal error-handling scheme.
(let ('err payload) (try (bail 100)))
(prn (int? payload)) ; prints #t
(bail (tab
('error-kind 'missing-resource-error)
('filename "space-station.lvl")
('resource-name 'laser-rifle)
('recoverable #t)))
try-verbose
is identical to try
, but when an error occurs it returns
(err payload stack-trace)
, where stack-trace
is a string describing the call-stack when the
error was generated. Stack-trace generation can cost a lot of time, sometimes in excess of one
millisecond, so try-verbose
should be used sparingly.
Exception Safety
There is a downside to capturing errors. If a function makes a series of changes which are globally visible (like mutating an object field, mutating a global variable, or enabling or disabling a state), an error could cause the function to suddenly stop executing, even if those changes are only partially complete. Capturing that error would leave your game in a buggy, incoherent state.
It's technically possible to guarantee coherency by using language features like
defer
, but maintaining exception safety across your whole codebase would be a
huge engineering challenge. For the average game codebase, it's certainly not worth the effort.
Instead, it's usually best to only capture errors when they originate from "read-only" code which doesn't mutate any global data. This might include your rendering code, or the function which deserializes a level file, or the function which writes a saved game to the file system. When you do capture an error, you should capture it as far down the call stack as possible, to limit the number of function calls which it might disrupt.
Debugging
GameLisp's debugging facilities are not yet very mature.
The dbg
macro works like Rust's dbg!()
: each argument's line number, form and
return value are printed to the standard error stream.
(let variable 10)
(dbg (+ 2 3) variable)
#|
prints:
[example.glsp:2] (+ 2 3) = 5
[example.glsp:2] variable = 10
|#
Likewise, the todo
macro is designed to resemble Rust's todo!()
. It calls
(bail)
with an error message along the lines of "not yet implemented"
.
The file-location
function returns a brief filename and line number
as a string, like "scripts/somewhere.glsp:42"
.
The stack-trace
function returns a full stack-trace string, as described
above.
Cleanup
You'll be familiar with the use of Drop
in Rust to clean up resources like file handles
and mutex locks.
This type of scoped resource-handling is less common in GameLisp, but you might still need
to execute cleanup code from time to time. This can be achieved using the defer
special
form. defer
executes a number of forms when control exits from its enclosing
lexical scope, whether that's because of normal execution, return
, continue
, break
,
restart-block
, finish-block
, or an uncaught error.
; prints: first second third fourth
(defer (prn "fourth"))
(do
(defer (pr "third "))
(do
(defer (pr "second "))
(pr "first "))
(bail)
(prn "this line is unreachable"))
yield
is more complicated. With yield
, it's possible to leave a lexical scope,
and then return to it later on using coro-run
. Many other languages simply don't perform cleanup
when yielding out of a coroutine, which can lead to unexpected resource leaks. To avoid this,
we provide the defer-yield
special form, which executes one form every
time a yield
exits its lexical scope, and another form every time coro-run
resumes into its
lexical scope.
The combination of defer
and defer-yield
is powerful. They allow you to guarantee that a
particular condition holds for the entire duration of a dynamic scope (that is, the forms which
fall within a particular lexical scope, and any functions which those forms call, and any
functions called by those functions...). We use this to provide the with-global
macro, which overrides the value of a global variable for the duration of a
dynamic scope:
(defmacro with-global (name value-form)
`(splice
(let old-value# (global '~name))
(let new-value# ~value-form)
(= (global '~name) new-value#)
(defer
(= (global '~name) old-value#))
(defer-yield
(do
(= new-value# (global '~name))
(= (global '~name) old-value#))
(do
(= old-value# (global '~name))
(= (global '~name) new-value#)))))
(defn hello-world ()
(prn "hello, world"))
; make all prn calls much more exciting within a dynamic scope
(let boring-prn prn)
(with-global prn (fn (..args) (boring-prn ..args "!!!")))
(hello-world) ; prints hello, world!!!
with-global
is as powerful as Racket's parameters and Common Lisp's dynamic variables, but we
were able to implement it as a simple macro, without any special support from the language.
Syntax Information
When an array is parsed from a file, it's tagged with a small amount of hidden syntax information which indicates the file and line number from which it was parsed. This syntax information is essential for reporting a meaningful backtrace when an error occurs.
You normally won't need to think about this. Macros, expand
and backquote
automatically
generate appropriate syntax information for you.
The exception is when you manually transform one array into another within a macro - for example,
by calling (arr ..(rev source-arr))
. The arr
function has no way of knowing that its result
should be syntactically similar to source-arr
, so syntax information is not preserved.
One solution is to clone
the source array, and then mutate the resulting array in-place,
perhaps by clear!
ing it and then rebuilding it from scratch. clone
preserves syntax
information, so the new array will have the same syntax information as the source array.
Alternatively, the map-syntax
function takes an array and a mapping
function, and returns a new array with the same syntax information, where each element was
created by calling (mapper source-element)
.