What I did in the meantime is the following "hackish" solution:
class DoneException extends Exception
boolean wasAborted = false
try {
parallel {
"main": {
sh "myCommand"
throw DoneException
},
"abortMonitor": {
try {
sleep(10, java.util.concurrent.TimeUnit.YEARS)
} catch (AbortException) {
wasAborted = true
}
},
failFast: true
}
} catch (DoneException e) {
} catch (Exception e) {
if (wasAborted) {
throw e } else {
}
}
This is the rough, untested sketch of how I worked around this limitation. There are other, less convoluted approaches if you are willing to grab into the internals via means of getContext. I decided to stick with the public API instead to spare myself some headaches.
Why this works (in theory):
The main insight for me was that the sleep command actually tells you if it was aborted (throws an AbortException) versus if it failed because of other reasons (throws a FlowInterruptedException). The "other reasons" are in this case a failure in a parallel branch as failFast is specified. This means that aborting a build will abort both the sh and the sleep step as they are outer leaves of the execution graph. If instead the sh step fails, it will cause the sleep inside the abortMonitor branch to fail as well. Why sleep was cancelled can be determined easily then by the type of exception that is thrown.
There is one caveat to this approach:
The "eternal" sleep has to be cancelled once the "main" branch was successful. This is done by throwing an exception of the custom type DoneException. This is abusing exceptions for non-exceptional control flow. Apart from being a bad pattern in general, it has the concrete downside of making the "bubble" for the sleep step in the workflow steps overview of the build appear red. I.e., you will have failing sleep steps in a successful build.
Manually stopping a build should set the result to ABORTED. This uses FlowInterruptedException, not AbortException.