Michael W. Bigrigg, Copyright
2004-2007
Exceptions can be used to provide additional information from a callee to a caller. This information may be the identification of an error condition, or it may simply be informative. For example, the C standard library fopen() function will return a file handle upon successful completion, and will return a NULL (typically a zero) to signify an error. However, the C fread() function will return the number of bytes read. The typical case is that the return value matches the number of bytes requested. The return value may be less than the number requested, informing the caller that less than the number of bytes remained in the file. If the value is a zero, it means that the end of file has been reached and that there is no data left. If the number is a negative value, an error has occurred.
The C fread return value possibilities:
We will classify an exception as informational data given from the callee to the caller such that the information returned is important to the further execution of the application. For example, the C math library sin() function which computes the sine of a value has a return value. This return value may be ignored. Of course it may seem to be ridiculous to compute a value and not use it, but the ignoring of the value of sin(), unlike the return value of fread(), does not effect the robustness of the application.
Exceptions are available at the language level and at an application level. Language exceptions are built into the language and the standard libraries such as the I/O processing done by the operating system. Application exceptions are part of the return message passed from one component to another.
Language exceptions are, of course, based on the programming language. The language C uses overloaded return values to identify an exception. In addition it uses an errno global variable to further explain the error. Object-oriented languages such as Java, C++, and Ada, have runtime exception mechanisms. With language enabled exception handling, it is much easier to determine if they exception was acknowledged or ignored.
Application exceptions are built into the protocol of communication between application components. This is necessary when communication is done between a device and the host computer in the case of the small computer system interface (SCSI) protocol. And also for the communica-tion mechanism between computers in the case of remote procedure calls (RPC), remote method invocation (RMI), or the common object request broker architecture (CORBA). It is not universal that all communication protocols express exceptions. For instance, the HTTP protocol does not have exceptions as a part of its protocol.
Both language-based exceptions and application-based exceptions must be dealt with through a recovery mechanism or by reporting the error condition back to the user. Missing exception handling does not lead to catastrophic crashes, but cause silent failures and infinite loops. Part of the process of building an exception injection system is to codify the exceptions. It should be noted that part of the problem in exception handling might be the arbitrary nature to the way exceptions are expressed. In C, the fopen() function returns a NULL (a zero in most systems) as its failure exception and the fclose() function returns an EOF (a -1 in most systems) as its failure exception. The lack of an orthogonal approach to exceptions may be a major cause in the misidentification of exceptions.
The Java language has language support for exceptions. It supports try, catch, and throw statements for not only identifying and processing exceptional conditions, but it also has a means to defer the processing of the exception to a higher level function. Unlike languages that use return values for exceptions, if an exception in Java propagates up the call stack and is not caught, it will crash when it reaches the top of a program without being caught.
File systems routinely make extraordinary attempts on behalf of the application to provide data whenever possible to the user. Problems such as network congestion or outages and heavily loaded systems can lead to failure-like situations, making it impossible for the file system to complete the entire requested operation. These situations are usually only transient and still enable the file system to provide a partial result. An example of a true failure condition for a file system is a request for data where no data is available, i.e., a read past the end of a file. This is different from a situation in which data is available, but is currently not accessible, such as when using a disconnected mobile device. When applications not intended for an unreliable environment are ported from a desktop environment to a wireless system, the application programmer must account for all such unpredictable behavior. As programmers, we overlook error checking when we are overwhelmed with the task of identifying all possible error situations, or neglect checking in the belief that some errors are inconceivable. .
Local file system interfaces are typically identical to those of a distributed file system, though the potential for failure at each is greatly different. In a local file system, failures are catastrophic. If the hard drive or other local storage device fails, it often signals the end of the device's usable lifetime. Failures in distributed file systems are more common and it is possible to recover from them. They are usually the result of unreachable remote storage devices or device overloading due to network partitioning, poor load balancing, or denial of service attacks. Users have fundamental, but not often expressed assumptions about the reliability of the system an application is built for. Yet unhandled error conditions lead to potential software failures when the underlying system cannot satisfy our requests and the application was built assuming that it can.
Errors are reported in C I/O routines using out-of-range values. The return values of these routines are either a useful result (upon successful completion of the call) or an indication of the error that occurred (upon an unsuccessful completion). For instance, the successful return of the fopen call is a handle to a file. The range of values for a file handle is an unsigned integer greater than zero. A zero, also referred to as NULL, is then used to report that the file system was unable to open the file. The return of a fread call uses out-of-range values to transmit not only an error condition, but also specifies an end of file condition as well. The return identifies the number of bytes that have actually been read. The fread call, like all data buffer operations, will read up to but no more than the number of bytes that have been requested. A return of zero does not signify an error condition, just that no data is currently accessible such as at the end of a file. It is a negative return value that signifies an error condition. Since a single value can potentially be both an error condition and also a valid result, it is not until tested that we know. Just Schroedinger's cat, we cannot tell what the value is until it is examined . When writing a program, we have to assume that both outcomes are likely and cannot assume one.
The values that specify an error condition are based on the I/O routine itself. An examination of the C standard I/O library shows the behavior of I/O function calls upon an error condition:
Only the data buffer operations (fprintf, fscanf, print, sprintf, sscanf, vfprintf, vsprintf, fputc, fputs, gets, putc, fread, and fwrite) overload the return with three potential values.
In addition, we must identify the result value. The result is the value achieved upon successful completion of the call and may be passed through a return or through an argument. The buffer operations have not only the result in the return but also an argument (a buffer), which is also a result. Not only is it important to distinguish the error from the result in the return, but also it is important to acknowledge the error before using the buffer contents. Therefore, error checking must occur before the use of any result values.
Correct error checking associated with an I/O routine must occur between the set (called a definition or simply def) of the potential error value and use of a result value or values along all possible paths of execution. For instance, the C code example in Figure 1 could lead to a program crash, while the code example in Figure 2 uses program logic to safeguard against a possible error condition.
fin = fopen("foo","r");
if (fin != NULL) {
fread(fin, sizeof(int), 10, buf);
}
We can augment traditional data flow analysis to identify missing error checking. Data flow analysis is a traditional technique used by compilers during the optimization phase as a tool to guarantee the correctness of program transformations. Value chains, called def-use chains, are identified between the definition of a value and the places the value is used. Data analysis is performed on values and not on variables. Figure 3 shows how a value chain is formed, dependent on the instance of a value in a variable, rather than on the name of the variable.
a = 3; /* def of a1 */
b = a + 5; /* use of a1, def of b1 */
a = 8; /* def of a2 */
We can augment the def-use chains to additionally include the check of a value. We define a check as a use of a value that additionally falls within the expression of a conditional statement. There is already a large body of work on the mechanisms for computing def-use chains. The conditional is a guard against incorrect usage of the result value. The conditional expression that acts as an error guard may be part of any conditional structure including if and if-else statements as well as while and repeat loops as shown in Figure 4.
n = fread (fin, sizeof(int), 1, buf);
while (n > 0) {
k += buf[0];
n = fread (buf, sizeof(int), 1, fin);
}
While set-check-use is a straightforward approach, there are a few issues to incorporate into that methodology. Error values and the result values are not bound to a specific variable as shown in Figure 5. These values can be assigned to other variables or even modified. In these cases, we need to track the values to make sure that the use of the result values does not occur before the check of the error values.
a = fopen ("foo.txt","r");
b = a;
if (b != NULL) {
n = fread(buf, sizeof(int), 1, a);
}
It is also important to note that the use of the result value need not exist only within the body of the conditional, and that the conditional may be used to reset the result variable as shown in Figure 6. Again this involves tracking the values through all execution paths. Once the tracked value is overridden with another value, the tracking of the previous value stops along that path of execution.
a = fopen ("foo.txt", "r");
if (a == NULL) {
a = stdin;
}
We know that there is a check, but that does not mean that the expression will accurately identify an error situation. Finally, in order to determine if the check is valid we must examine the conditional expression.
Another aspect to detecting missing robustness checks is the use of error information from the language, as outlined in the previous section, to guide the set-check-use approach by determining which value identifies the error. The error propagation information provides a heuristic approach similar to error classification schemes. An example is given to show how semantic information would drive the def-check-use analysis. In the case of file opening as shown in Figure 7, the def, check, and use locations use the same value for the analysis. Between the def and use of a, there should be a check of a.
a = fopen("foo","r"); /* def of a */
if (a != NULL) /* check of a */
fread(buf,sizeof(int),10,fin); /*use of a */
In an fopen call, the return holds the error value. A NULL return value designates an error condition. The def, check, and use is to use the same value, a, which is the value returned from the fopen call.
public static void main (String args[]) {
File f = null;
FileReader fr = null;
BufferedReader in = null;
try {
f = new File ("/tmp/foo");
}
catch (Exception e) {
System.out.println("Exception 1 "+e);
}
try {
fr = new FileReader(f);
}
catch (Exception e) {
System.out.println("Exception 2 "+e);
}
try {
in = new BufferedReader(fr);
}
catch (Exception e) {
System.out.println("Exception 3 "+e);
}
If the file does not exist, the following exceptions are thrown:
Exception 2 java.io.FileNotFoundException:
/tmp/foo (No such file or directory)
Exception 3 java.lang.NullPointerException
Because of the way this program is structured, the error related to the exception continues to propagate.