Repository: ipython/ipykernel

Issue:
OutStream.close raises if `watchfd=False`
Closing an `OutStream` instance via its `close` method fails if that `OutStream` instance is created with `watchfd=False`, with an exception `AttributeError: 'OutStream' object has no attribute 'watch_fd_thread'`.

Here's a full traceback from some Envisage tests. We're creating a kernel app with `kernel = IPKernelApp.instance(capture_fd_output=False)`, and then later shutting that kernel down. Only the last two lines are directly relevant for `ipykernel`. I'll see if I can come up with a more self-contained reproducer.

```python
======================================================================
ERROR: test_ipython_util_io_globals_restored (envisage.plugins.ipython_kernel.tests.test_internal_ipkernel.TestInternalIPKernel)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/mdickinson/Enthought/ETS/envisage/envisage/plugins/ipython_kernel/tests/test_internal_ipkernel.py", line 162, in test_ipython_util_io_globals_restored
    self.create_and_destroy_kernel()
  File "/Users/mdickinson/Enthought/ETS/envisage/envisage/plugins/ipython_kernel/tests/test_internal_ipkernel.py", line 319, in create_and_destroy_kernel
    kernel.shutdown()
  File "/Users/mdickinson/Enthought/ETS/envisage/envisage/plugins/ipython_kernel/internal_ipkernel.py", line 133, in shutdown
    self.ipkernel.close()
  File "/Users/mdickinson/Enthought/ETS/envisage/envisage/plugins/ipython_kernel/kernelapp.py", line 202, in close
    self.close_io()
  File "/Users/mdickinson/Enthought/ETS/envisage/envisage/plugins/ipython_kernel/kernelapp.py", line 296, in close_io
    sys.stderr.close()
  File "/Users/mdickinson/.venvs/envisage/lib/python3.10/site-packages/ipykernel/iostream.py", line 429, in close
    self.watch_fd_thread.join()
AttributeError: 'OutStream' object has no attribute 'watch_fd_thread'

----------------------------------------------------------------------
Ran 1 test in 0.161s

FAILED (errors=1)
```

From the source, the cause looks fairly clear: at `OutStream` creation time, the `_should_watch` and `watch_fd_thread` attributes are only [created if `watchfd=True`](https://github.com/ipython/ipykernel/blob/221dca63d7b2e2b8fea32bf8a101d07645fc9d3c/ipykernel/iostream.py#L390-L391), but the `close` method [assumes that both attributes exist](https://github.com/ipython/ipykernel/blob/221dca63d7b2e2b8fea32bf8a101d07645fc9d3c/ipykernel/iostream.py#L428-L429) unconditionally.  (Well, at least on Linux and macOS, anyway.)

ipykernel version: 6.9.1
Python version: 3.10.2
OS + hardware: macOS 11.6.3 (Big Sur), Intel MacBook Pro

Discussion:

Patch:
diff --git a/ipykernel/iostream.py b/ipykernel/iostream.py
index 92df61a..32829f6 100644
--- a/ipykernel/iostream.py
+++ b/ipykernel/iostream.py
@@ -401,6 +401,7 @@ class OutStream(TextIOBase):
         self._buffer = StringIO()
         self.echo = None
         self._isatty = bool(isatty)
+        self._should_watch = False
 
         if (
             watchfd
@@ -449,7 +450,7 @@ class OutStream(TextIOBase):
 
     def close(self):
         """Close the stream."""
-        if sys.platform.startswith("linux") or sys.platform.startswith("darwin"):
+        if self._should_watch:
             self._should_watch = False
             self.watch_fd_thread.join()
         if self._exc:


Test patch:
diff --git a/ipykernel/tests/test_io.py b/ipykernel/tests/test_io.py
index f7fc8b3..221af1f 100644
--- a/ipykernel/tests/test_io.py
+++ b/ipykernel/tests/test_io.py
@@ -98,6 +98,9 @@ def test_outstream():
         stream = OutStream(session, pub, "stdout")
         stream = OutStream(session, thread, "stdout", pipe=object())
 
+        stream = OutStream(session, thread, "stdout", watchfd=False)
+        stream.close()
+
     stream = OutStream(session, thread, "stdout", isatty=True, echo=io.StringIO())
     with pytest.raises(io.UnsupportedOperation):
         stream.fileno()