The default error policy is simple:
If correct execution says an operation cannot fail, let it fail loudly when it does.
Do not turn programming errors into control flow.
Do not convert violated invariants into ambiguous state.
Do not catch an error merely because seeing an error feels impolite.
pcall is not defensive programming
Do not wrap ordinary calls in pcall because they might throw.
Do not use protected execution to hide:
- incorrect API usage;
- malformed registrations;
- impossible state;
- missing required dependencies;
- type errors;
- bugs in your own callback;
- failures you cannot actually recover from.
If the only recovery plan is “print something and continue as though the operation worked,” you do not have a recovery plan.
I will hunt you for using pcall.
There. Now read the exceptions instead of cargo-culting the threat.
A protected call needs a boundary
Protected execution is justified when failure is part of the boundary's contract and you have something specific to do with it.
Three recurring legitimate cases appear in the corpus.
1. Arbitrary external/plugin code
DreamScripts' script loader eventually had to execute arbitrary compiled script chunks. An unhandled script error could escape through the host boundary and crash the process path.
DreamScripts commit 219c6e4b wrapped that execution, logged the failure, and stopped the server.
The protected call did not convert a broken script into success. It translated an uncontrolled host failure into a controlled fatal boundary.
That is legitimate.
2. Restore invariants, then rethrow
H3's Signal:fire uses xpcall around listener invocation so that listener mutations and firing state can be cleaned up even when a callback throws. The error is then rethrown.
The goal is not suppression.
The goal is:
The safe failure shape
A protected callback is legitimate when it restores an invariant and still propagates the failure.
-
input
Callback fails
A listener throws while the owner is in a sensitive operation.
-
boundary
Restore Signal invariants
Reset firing state and listener bookkeeping.
-
result
Propagate the failure
Do not silently convert a broken callback into success.
How the pieces relate
- Callback fails Restore Signal invariants
- Restore Signal invariants Propagate the failure
That is legitimate.
3. Introspection APIs where absence/failure is data
Pr0f1l3r probes LuaJIT/debug facilities that can legitimately be unavailable or reject a particular trace/function/PC combination. The profiler must be able to record partial telemetry rather than crash because one introspection query has no answer.
That is legitimate because the operation's contract includes “this information may not be obtainable.”
4. Capability probing where failure is the answer
ProtectedTable uses protected execution when probing a storage section's write capability. The attempted operation is explicitly expected to fail for read-only sections, and that failure itself supplies the information being requested. The constructor uses the result to choose the write path; it does not hide a programming error and continue as though the write succeeded.
That is legitimate.
Context probing is an edge case, not a design model
Historical Starwind code and current H3 compatibility helpers use protected require calls to identify the active script context.
That works because failure itself is the signal being queried.
Do not generalize that into ordinary module architecture. Cod3x context annotations and context-specific entry points are preferable for normal code.
See Probing Context by Failure.
Preserve the diagnostic
If you must catch an error, do not destroy the useful information.
DreamScripts has a delightful pair of historical fixes whose subjects amount to “we should probably actually print the traceback.” That is exactly the lesson.
An error boundary should preserve:
- the original message;
- traceback when useful;
- the operation being attempted;
- relevant IDs/paths/context;
- whether execution continued or terminated.
Catching an error and replacing it with failed is vandalism.
Recovery must restore a valid state
Suppose a callback mutates three structures and throws after the first.
If you catch the error and continue, which structures are authoritative now?
Unless the boundary is transactional, can roll back, can rebuild, or can safely discard the work, continuing may be worse than stopping.
“Robustness” means preserving valid behavior, not maximizing uptime at any cost.
Fatal means fatal
When an invariant violation makes future behavior unknowable, terminate the feature or process at the appropriate level.
S3maphore's CellPresence collection was changed in commit c100e5eb so collection errors are fatal rather than tolerated. That choice reflects the state machine's dependency on correct presence data.
A half-valid resolver is not more user-friendly than an error.
core.quit() is not return
OpenMW-specific warning: requesting game termination does not imply the current Lua function immediately stops executing.
Commit e3e21b64 exists because code continued after calling core.quit().
H3 Pattern: use Result when a caller needs explicit success/failure data, not a hidden protected call. Use Signal when synchronous listener cleanup and rethrowing are the actual boundary contract.
If execution must stop locally, return.
See Quitting Is Not Returning.
See also
For a practical demonstration of an architecture in which protected execution is applied without meaningful discrimination, see PCallManager.