Making CALL Raise a (definitional) Error For Bad Exit Codes

Before definitional errors existed, I noticed that @gchiu had some uses of CALL that weren't checking the return code.

call [
    gs -sDEVICE=pngmono -o (join root "-%02d.png") -r600 (pdfname)

call [
    gs -sDEVICE=eps2write -sPAPERSIZE=a4
        -o (join root "-%02d.eps") (pdfname)

I wanted the default to raise an error if the gs (GhostScript) process did not return a 0 exit code. But I wanted that error to be a result of CALL... so it would be distinct from other errors (like a typo in the code in the groups inside, pfdname instead of pdfname or whatever).

CALL was already a wrapper on top of an internal CALL* function, so I thought "why not expand upon that?"


call: enclose (
    augment (specialize :call* [wait: #]) [
        /relax "If exit code is non-zero, return the integer vs. raising error"
) func [f [frame!]] [
    let relax: f.relax
    let result: do f
    if relax or (result = 0) [
        return result
    return raise make error! compose [
        message: ["Process returned non-zero exit code:" exit-code]
        exit-code: (result)

I think that's pretty neat.

It twists the CALL* function so that it always waits (vs. spawn a separate process and return a process ID to wait on).

Then it offers a /RELAX setting for getting the exit code back, if you don't want the definitional error behavior.

But then, by default it will RAISE an error. You can get that error via EXCEPT or you can do TRY CALL if you just want to ignore any errors.

Issue Exposed: Who's Actually To Blame?

While making this I noticed that there were actually several points of failure:

  • The guts of CALL might not be able to find the file you're asking to execute

  • The executable may run, but return a non-zero exit code

  • If you're running through CALL/SHELL and delegating to it to call a process, then the shell may have its own exit status distinct from the process you tried to call

It seems there's not really any great way to untangle the return results of a shell from that of a process it executes. Here's some of the informal conventions of UNIX shells:

"When a command is terminated by a signal whose number is N, a shell sets the variable $? to a value greater than 128. Most shells use 128+N, while ksh93 uses 256+N."

"If a command is not found, the shell should return a status of 127. If a command is found but is not executable, the return status should be 126."

So I'm a little shaky on what exactly a TRY CALL should be ignoring. It's one thing to ignore a program's exit status, and another to ignore whether the program was on disk at all.

TAKE of a BLOCK only has a definitional failure when the block is empty, so you know what TRY TAKE means. But it may be that you should more or less never say TRY CALL, and always specifically handle the errors that arise from it. I think this may be a common theme of definitional errors coming out of complicated functions which have more than a single way to fail.

But... in any case, it's progress. Because we're not conflating typos or other incidental errors to those that are coming from CALL. And I like the default that it has an error on non-zero exit statuses.

1 Like