Thanks to @marco's comment, this is a simple answer!
A try
block can be used in the custom step and it allows catching even top-level Jenkins exceptions:
// myTask.groovy
void call(Map config = [:]) {
try {
// long running task
} finally {
// perform cleanup
}
}
In our case, this even catches the exception thrown from our use of the Build Timeout plugin.