tags -1 +patch
thanks

I believe I have tracked down the problem.  The changes introduced in
fec9e97c51b3a8ff226a4b3b2b0563a4a680ac68 cause a file object to be
manipulated on both sides of a thread boundary, which seems dangerous. 
The attached patch restructures things to avoid that problem, and a few
other implementation issues.  The commit message has more details about
the rationale.

I've also attached a script I used to help reproduce the issue.  It
doesn't do so reliably, and it's not totally robust itself, but I found
it more reliable than trying to time timeout or a ^C right.

fec9d97 was meant to improve portability.  It's possible that my patch
has its own portability problems: it opens a FIFO in nonblocking mode,
and fifo(7) makes it sound like this behavior is undefined by POSIX.  We
might want to check with some BSD folks to see what they do in that
case.  But even if the patch doesn't work as intended outside Linux,
hopefully fixing that is a matter of checking for more error codes or
something like that, and won't require as much restructuring as fec9d97 did.

I should note that the cleanup that we do after receiving a signal is a
little incomplete in the current state, too.  What happens now is: when
we get a signal, we raise a SystemExit exception in the main thread. 
That bubbles all the way back up to diffoscope.main.main, which cleans
up all the temporary files and directories in a finally block, and then
the Python process exits.  However, subprocesses and all of our threads
(which are marked as daemon threads) are still running, and have all the
file descriptors open.  So the files still "exist" in some sense and are
taking up space, even if they're no longer addressable.  We're also
still spending plenty of CPU doing the real diff work.  Eventually the
processes will all go away because they finished their work naturally,
or because they encounter an error because their working directories
have disappeared out from under them.

It would be nice to move to a system where, when we get a signal, we
send it to all our children, then wait for them to finish before doing
our own cleanup.  But that's a bigger job and less urgent, so I didn't
go that far here.

-- 
Brett Smith

>From 224f61e67fa3880c6f13b6fcd580f90cd53a4522 Mon Sep 17 00:00:00 2001
From: Brett Smith <[email protected]>
Date: Fri, 27 Jan 2017 22:42:23 -0500
Subject: [PATCH] diffoscope.diff: Improve FIFO writing robustness.

We used to give input to diff using FIFO objects.
fec9e97c51b3a8ff226a4b3b2b0563a4a680ac68 changed this to avoid relying on
/dev/fd, which is a good change for portability.  Unfortunately, the
implementation has a few issues of its own:

* It feeds diff with normal files, rather than FIFOs.  This could cause
  trouble if diff tries to read farther than the underlying feeder
  processes have written.
* It uses threads to write to the pseudo-FIFO, but the file object is
  manipulated in multiple threads.  I suspect this is the primary cause of
  the segfaults observed in #852013.
* fd_from_feeder is decorated as a context manager, but it never yields
  anything, which is not how it's expected to be used.  I'm not sure this is
  causing any problems, but it makes it harder to reason about what's going
  on.

This commit introduces a new FIFOFeeder class.  It is wholly responsible for
creating and feeding the FIFO, so we don't have to pass file objects across
the thread boundary and risk segfaults.  It uses context management to tell
when the FIFO is no longer needed, so it can clean up nicely.
---
 diffoscope/diff.py | 103 +++++++++++++++++++++++++++--------------------------
 1 file changed, 52 insertions(+), 51 deletions(-)

diff --git a/diffoscope/diff.py b/diffoscope/diff.py
index 011916a..0335a26 100644
--- a/diffoscope/diff.py
+++ b/diffoscope/diff.py
@@ -19,6 +19,9 @@
 
 import re
 import io
+import os
+import errno
+import fcntl
 import hashlib
 import logging
 import threading
@@ -190,54 +193,55 @@ def run_diff(fifo1, fifo2, end_nl_q1, end_nl_q2):
 
     return parser.diff
 
-def feed(feeder, f, end_nl_q):
-    # work-around unified diff limitation: if there's no newlines in both
-    # don't make it a difference
-    try:
-        end_nl = feeder(f)
-        end_nl_q.put(end_nl)
-    finally:
-        f.close()
-
-class ExThread(threading.Thread):
-    """
-    Inspired by https://stackoverflow.com/a/6874161
-    """
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.__status_queue = Queue()
-
-    def run(self, *args, **kwargs):
-        try:
-            super().run(*args, **kwargs)
-        except Exception as ex:
-            #except_type, except_class, tb = sys.exc_info()
-            self.__status_queue.put(ex)
-
-        self.__status_queue.put(None)
+class FIFOFeeder(threading.Thread):
+    def __init__(self, feeder, fifo_path, end_nl_q=None, *, daemon=True):
+        os.mkfifo(fifo_path)
+        super().__init__(daemon=daemon)
+        self.feeder = feeder
+        self.fifo_path = fifo_path
+        self.end_nl_q = Queue() if end_nl_q is None else end_nl_q
+        self._exception = None
+        self._want_join = threading.Event()
 
-    def wait_for_exc_info(self):
-        return self.__status_queue.get()
+    def __enter__(self):
+        self.start()
+        return self
 
-    def join(self):
-        ex = self.wait_for_exc_info()
-        if ex is None:
-            return
-        raise ex
+    def __exit__(self, exc_type, exc_value, exc_tb):
+        self.join()
 
[email protected]
-def fd_from_feeder(feeder, end_nl_q, fifo):
-    f = open(fifo, 'wb')
-    t = ExThread(target=feed, args=(feeder, f, end_nl_q))
+    def run(self):
+        try:
+            # Try to open the FIFO nonblocking, so we can periodically check
+            # if the main thread wants us to wind down.  If it does, there's no
+            # more need for the FIFO, so stop the thread.
+            while True:
+                try:
+                    fifo_fd = os.open(self.fifo_path, os.O_WRONLY | os.O_NONBLOCK)
+                except OSError as error:
+                    if error.errno != errno.ENXIO:
+                        raise
+                    elif self._want_join.is_set():
+                        return
+                else:
+                    break
+
+            # Now clear the fd's nonblocking flag to let writes block normally.
+            fcntl.fcntl(fifo_fd, fcntl.F_SETFL, 0)
+            with open(fifo_fd, 'wb') as fifo:
+                # The queue works around a unified diff limitation: if there's
+                # no newlines in both don't make it a difference
+                end_nl = self.feeder(fifo)
+                self.end_nl_q.put(end_nl)
+        except Exception as error:
+            self._exception = error
 
-    t.daemon = True
-    t.start()
+    def join(self):
+        self._want_join.set()
+        super().join()
+        if self._exception is not None:
+            raise self._exception
 
-    try:
-        t.join()
-    finally:
-        f.close()
 
 def empty_file_feeder():
     def feeder(f):
@@ -273,15 +277,12 @@ def make_feeder_from_raw_reader(in_file, filter=lambda buf: buf):
     return feeder
 
 def diff(feeder1, feeder2):
-    end_nl_q1 = Queue()
-    end_nl_q2 = Queue()
-
     with tempfile.TemporaryDirectory() as tmpdir:
-        fifo1 = '{}/f1'.format(tmpdir)
-        fifo2 = '{}/f2'.format(tmpdir)
-        fd_from_feeder(feeder1, end_nl_q1, fifo1)
-        fd_from_feeder(feeder2, end_nl_q2, fifo2)
-        return run_diff(fifo1, fifo2, end_nl_q1, end_nl_q2)
+        fifo1_path = os.path.join(tmpdir, 'fifo1')
+        fifo2_path = os.path.join(tmpdir, 'fifo2')
+        with FIFOFeeder(feeder1, fifo1_path) as fifo1, \
+             FIFOFeeder(feeder2, fifo2_path) as fifo2:
+            return run_diff(fifo1_path, fifo2_path, fifo1.end_nl_q, fifo2.end_nl_q)
 
 def reverse_unified_diff(diff):
     res = []
-- 
2.1.4


Attachment: test.sh
Description: application/shellscript

Reply via email to