Repository: stb-tester/stb-tester

Issue:
stbt run ignores ^C
@szemate reports:

> stbt-run ignores ^C (keyboard interrupt).
> It prints the following exception and does not exit:
> 
> ```
> Exception in thread Thread-1:
> Traceback (most recent call last):
> File "/usr/lib64/python2.7/threading.py", line 811, in
> __bootstrap_inner
>   self.run()
> File "/usr/lib64/python2.7/threading.py", line 764, in run
>   self.__target(*self.__args, **self.__kwargs)
> File "/usr/lib64/python2.7/site-packages/gi/overrides/GLib.py", line 518, in run
>   raise KeyboardInterrupt
> KeyboardInterrupt
> ```
> 
> then at the end of the test it raises the following exception:
> 
> ```
> Traceback (most recent call last):
> File "/usr/lib64/python2.7/site-packages/gi/overrides/GLib.py", line
> 629, in <lambda>
>   return (lambda data: callback(*data), user_data)
> File "/usr/libexec/stbt/stbt.py", line 1375, in restart_source
>   self.source_pipeline.set_state(Gst.State.NULL)
> AttributeError: 'NoneType' object has no attribute 'set_state'
> ```

This is with the HDPVR and the following stbt.conf:

```
[global]
source_pipeline = v4l2src device=/dev/video0 ! tsdemux ! h264parse
sink_pipeline = xvimagesink sync=false
restart_source = true
```



Discussion:
I can confirm this behaviour.  This is strange because the [Python documentation for signal](https://docs.python.org/2/library/signal.html) says:

> the main thread will be the only one to receive signals

But the Python documentation for thread says:

> Threads interact strangely with interrupts: the KeyboardInterrupt exception will be received by an arbitrary thread. (When the signal module is available, interrupts always go to the main thread.)

Hmm.  There is [`thread.interrupt_main()`](https://docs.python.org/2/library/thread.html#thread.interrupt_main):

> Raise a KeyboardInterrupt exception in the main thread. A subthread can use this function to interrupt the main thread.

[`threading`](https://docs.python.org/2/library/threading.html) says:

> There is a “main thread” object; this corresponds to the initial thread of control in the Python program.

Have you tried explicitly setting a signal handler for `SIGINT` (for CTRL-C) in your main thread?

When you quote "the main thread will be the only one to receive signals" this doesn't mean that the OS guarantees that it will only deliver signals to the main thread, it's just stating a limitation of Python. Reading `man pthreads`:

> POSIX.1 distinguishes the notions of signals that are directed to the process as a whole and signals that are directed to individual threads. According to POSIX.1, a process-directed signal (sent using kill(2), for example) should be handled by a single, **arbitrarily selected** thread within the process.

I remember resolving this sort of issue in a previous job by explicitly setting signal handlers in your main thread before executing any code, a-la:

http://pymotw.com/2/signal/#signals-and-threads

but YMMV.

IIRC I've seen this with other capture devices to, so I _think_ it isn't HDPVR specific. I've updated the title of this issue accordingly.


Resolving patch:
diff --git a/stbt.py b/stbt.py
--- a/stbt.py
+++ b/stbt.py
@@ -1196,7 +1196,14 @@ def teardown_run():
 #===========================================================================
 
 _debug_level = 0
-_mainloop = GLib.MainLoop()
+if hasattr(GLib.MainLoop, 'new'):
+    _mainloop = GLib.MainLoop.new(context=None, is_running=False)
+else:
+    # Ubuntu 12.04 (Travis) support: PyGObject <3.7.2 doesn't expose the "new"
+    # constructor we'd like to be using, so fall back to __init__.  This means
+    # Ctrl-C is broken on 12.04 and threading will behave differently on Travis
+    # than on our supported systems.
+    _mainloop = GLib.MainLoop()
 
 _display = None
 _control = None
@@ -1648,10 +1655,7 @@ def on_eos(_appsink):
             done.set()
             return True
         hid = appsink.connect('eos', on_eos)
-        if appsink.get_property('eos'):
-            appsink.disconnect(hid)
-            return True
-        d = done.wait(timeout)
+        d = appsink.get_property('eos') or done.wait(timeout)
         appsink.disconnect(hid)
         return d
 
@@ -1660,6 +1664,8 @@ def teardown(self):
         self.source_pipeline, source = None, self.source_pipeline
         if source:
             for elem in gst_iterate(source.iterate_sources()):
+                elem.send_event(Gst.Event.new_flush_start())
+                elem.send_event(Gst.Event.new_flush_stop(False))
                 elem.send_event(Gst.Event.new_eos())
             if not self.appsink_await_eos(
                     source.get_by_name('appsink'), timeout=10):


Test patch:
diff --git a/tests/test-stbt-batch.sh b/tests/test-stbt-batch.sh
--- a/tests/test-stbt-batch.sh
+++ b/tests/test-stbt-batch.sh
@@ -144,7 +144,7 @@ expect_runner_to_say() {
     fail "Didn't find '$1' after 10 seconds"
 }
 
-test_stbt_batch_run_sigint_once() {
+test_stbt_batch_run_sigterm_once() {
     create_test_repo
     sleep=4 stbt batch run tests/test.py &
     runner=$!
@@ -155,7 +155,7 @@ test_stbt_batch_run_sigint_once() {
     diff -u <(echo success) latest/failure-reason || fail "Bad failure-reason"
 }
 
-test_stbt_batch_run_sigint_twice() {
+test_stbt_batch_run_sigterm_twice() {
     create_test_repo
     sleep=10 stbt batch run tests/test.py &
     runner=$!
diff --git a/tests/test-stbt-run.sh b/tests/test-stbt-run.sh
--- a/tests/test-stbt-run.sh
+++ b/tests/test-stbt-run.sh
@@ -72,3 +72,39 @@ test_that_stbt_run_saves_screenshot_on_precondition_error() {
     ! stbt run -v test.py &&
     [ -f screenshot.png ]
 }
+
+test_that_stbt_run_exits_on_ctrl_c() {
+    # Enable job control, otherwise bash prevents sigint to background command.
+    set -m
+
+    cat > test.py <<-EOF
+	from time import sleep
+
+	for c in range(5, 0, -1):
+	    print "%i bottles of beer on the wall" % c
+	    sleep(1)
+
+	print "No beer left"
+	EOF
+    stbt run test.py >beer.txt &
+    STBT_PID=$!
+
+    sleep 1
+    kill -INT "$STBT_PID"
+    wait "$STBT_PID"
+    exit_status=$?
+
+    ! grep -q "No beer left" beer.txt || fail "Test script should not have completed"
+    [ "$exit_status" != "0" ] || fail "Unexpected return code $exit_status"
+}
+
+# A regression test
+test_that_stbt_run_exits_gracefully_with_non_live_sources() {
+    cat > test.py <<-EOF
+	wait_for_match("$testdir/videotestsrc-gamut.png", timeout_secs=1)
+	EOF
+    stbt run -v --source-pipeline="videotestsrc" test.py >log.log 2>&1
+
+    ! grep -q "Source pipeline did not teardown gracefully" log.log ||
+        fail "Source pipeline did not teardown gracefully"
+}