A Very Subtle Bug

6.033, MIT’s class on computer systems, has as one of its catchphrases, “Complex systems fail for complex reasons”. As a class about designing and building complex systems, it’s a reminder that failure modes are subtle and often involve strange interactions between multiple parts of a system. In my own experience, I’ve concluded that they’re often wrong. I like to say that complex systems don’t usually fail for complex reasons, but for the simplest, dumbest possible reasons — there are just more available dumb reasons. But sometimes, complex systems do fail for complex reasons, and tracking them down does require understanding across many of the different layers of abstraction we’ve built up. This is a story of such a bug.

The following code snippet in Python is intended to extract and return a single file from a tarball. It probably should be using tarfile, but let’s ignore that for the moment.

import subprocess
def extractFile(tarball, path):
  p = subprocess.Popen(['tar', '-xzOf', tarball, path],
                       stdout=subprocess.PIPE,
                       stderr=subprocess.PIPE)
  contents, err = p.communicate()
  if p.returncode:
    raise SomethingWentWrong(err)
  return contents

This code has a bug. It will often work just fine, but occasionally it will fail, with ‘err’ containing a message including gzip: stdout: Broken pipe. If, however you were to write the equivalent code in a shell script:

 contents="$(tar -xzOf "$tarball" "$path")"

you would find that it never fails in this way. So what’s going on?

When we launch tar with the -z option, it creates a pipe(7) and forks off a gzip process writing into one end of the pipe. It then reads the uncompressed tarball from the other end of the pipe, parsing our tar headers, until eventually it’s done, and it closes the read end of the pipe.

Since we’ve given tar a specific path to extract, it doesn’t need to read the entire tarball — only up until it can find that file. So it may close the pipe before reading the entire file, and, correspondingly, before gzip is done writing to it. As explained in pipe(7):

If all file descriptors referring to the read end of a pipe have been closed, then a write(2) will cause a SIGPIPE signal to be generated for the calling process. If the calling process is ignoring this signal, then write(2) fails with the error EPIPE.

Under normal circumstances, gzip expects that whoever is downstream of it may only care about a prefix of the uncompressed stream, and so it registers a SIGPIPE handler which exits cleanly.

Python, however, doesn’t want to get SIGPIPEs. Instead, Python would rather just check the return value of every write call it makes, and raise an IOError if necessary, so that Python code gets the error in an appropriately Pythonic way, instead of through an asynchronous signal. And so, at startup, Python uses signal(2) or sigaction(2) to ignore SIGPIPE by setting it to SIG_IGN.

As explained in sigaction(2):

A child created via fork(2) inherits a copy of its parent’s signal dispositions. During an execve(2), the dispositions of handled signals are reset to the default; the dispositions of ignored signals are left unchanged.

And so, when started from Python, gzip starts up with SIGPIPE ignored. And, for reasons I don’t understand, rather than unconditionally handling SIGPIPE, gzip first checks whether or not it’s ignored, and only installs a handler if the signal is not being ignored.

And so, SIGPIPE continues to be ignored, which means that gzip‘s write(2) returns EPIPE, which gzip sees is nonzero, calls perror on, and then exits. tar’s wait then sees gzip exit uncleanly, which causes tar itself to exit uncleanly, which Python then raises as an exception.

There’s an easy workaround, which is to re-enable SIGPIPE in the subprocess child:

  p = subprocess.Popen(['tar', '-xzOf', tarball, path],
                       stdout=subprocess.PIPE,
                       stderr=subprocess.PIPE,
                       preexec_fn=lambda:
                        signal.signal(signal.SIGPIPE, signal.SIG_DFL))

But who would think of doing that, without first having seen this horribly subtle chain of bug, and having to track down what went wrong?

(Here’s the Python bug report, and many thanks to cjwatson for posting his discovery of this class of bug on his blog, which greatly reduced the amount of time I would have had to spend tracking this down)

Feb 27th, 2010
  1. Feb 28th, 2010 at 05:33 | #1

    “Complex systems fail for complex reasons.” I agree, this is a quotation that does not accurately capture reality. In fact, complex systems fail for simple reasons. However, the complexity makes it far more difficult to find the simple reason, in part because there are so many possible simple reasons.

    A classic example of this is the Columbia shuttle disaster, in which a simple piece of falling foam doomed the mission.

  2. Thanassis
    Mar 1st, 2010 at 09:24 | #2

    I understand the complex interactions now that you’ve described them – what I don’t understand is how you sometimes have the command succeed, and sometimes fail… With SIGPIPE set to SIG_IGN, shouldn’t you ALWAYS get an EPIPE, and therefore an error at the Python level?

  3. Mar 1st, 2010 at 11:22 | #3

    The short version is that there’s a race condition — the kernel will buffer some amount of data, so it’s possible for gzip to write the remaining data into the kernel pipe buffer (without tar reading it), and exit before tar closes the pipe, in which case there’s no SIGPIPE.

    In response to this post and reddit discussion, a friend of mine did some tar source-diving and figured out some more about what’s going on, which you can read on his blog.

  4. Mar 1st, 2010 at 13:22 | #4

    Followup from the GNU tar maintainer here.

  5. tranto
    Mar 2nd, 2010 at 05:51 | #5

    nelhage :

    The short version is that there’s a race condition — the kernel will buffer some amount of data, so it’s possible for gzip to write the remaining data into the kernel pipe buffer (without tar reading it), and exit before tar closes the pipe, in which case there’s no SIGPIPE.

    Surely this can only succeed with fairly short .tar files then? I mean, if you want an entry from the beginning or the middle of a large archive, there’s no way the contents following the entry will fit in a pipe buffer.

  6. Mar 11th, 2010 at 00:33 | #6

    In 6.033, “complex systems fail for complex reasons” does refer to the subtle interactions between components, but it also is meant to serve as a reminder that technical root causes have deeper organizational and societal root causes: the “Keep Digging” principle.

    In fact, the Rogers Report on the Space Shuttle Challenger disaster is a classic example of the Keep Digging principle. The Columbia disaster does not have the same classic status, but NASA applied similar thoroughness in its analysis of that event.

Leave a comment

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>