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?"

A Cool ENCLOSE of an AUGMENT of a SPECIALIZE!

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

So I twisted the bootstrap executable to this notion of CALL waiting-by-default...and also treating a non-zero exit code as an error unless you said CALL/RELAX.

This made me wonder though: what should the result of CALL be if you don't say /RELAX ?

If it's still going to be a status, it will always be 0. But this suggests you might have something to learn by testing the result.

A misguided person might write:

if 1 = call ["something.exe" (filename)] [
   print "This could never happen since CALL would error on non-0 exit code"
]

This is where we might argue that using trash as the "useless success result" could be beneficial... if trash wasn't something you could use plain comparison on. That was the case in Rebol2 with unsets:

rebol2>> #[unset!] = #[unset!]
** Script Error: Operator is missing an argument

But Red and R3-Alpha allow it...

r3-alpha>> #[unset] = #[unset]
== true

red>> #(unset) = #(unset)  ; yes, a third trivially different notation
== true

Ren-C has thus far followed suit, without backing away from the idea that you can compare trash to itself (or other values).

ren-c>> ~ = ~
== ~true~  ; anti

But is this progress?

I just posted a challenge to the value of considering trash to be neither truthy nor falsey. Note that conditional orneryness is somewhat pointless, as it provides a protection that wouldn't help the case you're supposedly distinguishing from: if CALL returned an integer unconditionally a blind conditional check would be a bug, since all integers (including zero) are truthy in the language.

If equality tests to trash were ornery that seems it would help something. So I think it's worth asking what would be broken if you couldn't.

In any case...I didn't change plain CALL without /RELAX to return trash yet, but I might do so. It has the benefit of omitting an "==" output as well due to the console suppression of trash.

>> call/shell "dir"
 Volume in drive C has no label.
 Volume Serial Number is 72AF-4302

 Directory of C:\Projects\ren-c\prebuilt

03/09/2024  08:32 AM    <DIR>          .
04/26/2024  03:36 PM    <DIR>          ..
11/07/2014  02:53 PM           563,560 r3-alpha
04/27/2019  04:01 PM         1,363,856 r3-linux-x64-8994d23
10/20/2023  01:23 PM         1,298,055 r3-windows-x86-8994d23.exe
10/20/2023  01:20 PM             1,656 README.md
10/22/2023  01:37 AM           864,256 rebol2.exe
03/09/2024  08:30 AM         1,607,680 red.exe
               6 File(s)      5,699,063 bytes
               2 Dir(s)  1,592,278,437,888 bytes free

>>

If 0 was left as the result, that would say "== 0" before the ">>"