We ended up having to wrap the timeout step to make it more informative when the timeout occurs.
When the call to timeout throws an error, we determine whether the duration the step took was longer than the timeout. If it took longer, we throw a different TimeoutException which states the time taken and the step being executed, so that someone reading the build log can figure out whether the failure was a timeout or not, and which step took too long.
Unfortunately, there is an edge case where the step takes just longer than the timeout, where it is possible for the step to take slightly too long, but not actually fail due to the timeout. In this situation, we can't tell the difference.
The timeout step itself seems to know when it interrupted the step, so it could do this for us in a much more precise way.