Repository: aio-libs/aiohttp-sse

Issue:
Cancel ping_task on connection failure while sending event
When attempting to execute `EventSourceResponse.send()`, the connection may be interrupted, resulting in a `ConnectionResetError` (for example, if the client closed the browser).

Already at this moment we know that the connection is closed and cannot be restored.

But `EventSourceResponse._ping_task` is still working... When the EventLoop pays attention to the Task, the `_ping_task` will try to send a `: ping` to the closed connection.

Because the actions performed by the Task are doomed to failure in advance, we can free him from useless work and cancel the Task on `send()` failure

Comments:

Patch:
diff --git a/.coveragerc b/.coveragerc
index e1ba26a..0e3ab35 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -5,4 +5,5 @@ branch = True
 [report]
 exclude_also =
     ^\s*if TYPE_CHECKING:
+    : \.\.\.(\s*#.*)?$
     ^ +\.\.\.$
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6cc2b3f..96536e0 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -12,7 +12,7 @@ repos:
   hooks:
   - id: isort
 - repo: https://github.com/psf/black
-  rev: '23.12.1'
+  rev: '24.1.1'
   hooks:
     - id: black
       language_version: python3 # Should be a command that runs python3.6+
diff --git a/aiohttp_sse/__init__.py b/aiohttp_sse/__init__.py
index 8367e1f..43e6d50 100644
--- a/aiohttp_sse/__init__.py
+++ b/aiohttp_sse/__init__.py
@@ -51,7 +51,7 @@ class EventSourceResponse(StreamResponse):
         self.headers["Connection"] = "keep-alive"
         self.headers["X-Accel-Buffering"] = "no"
 
-        self._ping_interval: int = self.DEFAULT_PING_INTERVAL
+        self._ping_interval: float = self.DEFAULT_PING_INTERVAL
         self._ping_task: Optional[asyncio.Task[None]] = None
         self._sep = sep if sep is not None else self.DEFAULT_SEPARATOR
 
@@ -129,7 +129,11 @@ class EventSourceResponse(StreamResponse):
             buffer.write(self._sep)
 
         buffer.write(self._sep)
-        await self.write(buffer.getvalue().encode("utf-8"))
+        try:
+            await self.write(buffer.getvalue().encode("utf-8"))
+        except ConnectionResetError:
+            self.stop_streaming()
+            raise
 
     async def wait(self) -> None:
         """EventSourceResponse object is used for streaming data to the client,
@@ -164,19 +168,19 @@ class EventSourceResponse(StreamResponse):
         return self._req.headers.get(self.DEFAULT_LAST_EVENT_HEADER)
 
     @property
-    def ping_interval(self) -> int:
+    def ping_interval(self) -> float:
         """Time interval between two ping massages"""
         return self._ping_interval
 
     @ping_interval.setter
-    def ping_interval(self, value: int) -> None:
+    def ping_interval(self, value: float) -> None:
         """Setter for ping_interval property.
 
-        :param int value: interval in sec between two ping values.
+        :param value: interval in sec between two ping values.
         """
 
-        if not isinstance(value, int):
-            raise TypeError("ping interval must be int")
+        if not isinstance(value, (int, float)):
+            raise TypeError("ping interval must be int or float")
         if value < 0:
             raise ValueError("ping interval must be greater then 0")
 
@@ -221,8 +225,7 @@ def sse_response(
     reason: Optional[str] = None,
     headers: Optional[Mapping[str, str]] = None,
     sep: Optional[str] = None,
-) -> _ContextManager[EventSourceResponse]:
-    ...
+) -> _ContextManager[EventSourceResponse]: ...
 
 
 @overload
@@ -234,8 +237,7 @@ def sse_response(
     headers: Optional[Mapping[str, str]] = None,
     sep: Optional[str] = None,
     response_cls: Type[ESR],
-) -> _ContextManager[ESR]:
-    ...
+) -> _ContextManager[ESR]: ...
 
 
 def sse_response(


Test patch:
diff --git a/tests/test_sse.py b/tests/test_sse.py
index 0163c6a..6aae5d6 100644
--- a/tests/test_sse.py
+++ b/tests/test_sse.py
@@ -133,19 +133,19 @@ def test_compression_not_implemented() -> None:
 
 
 class TestPingProperty:
-    @pytest.mark.parametrize("value", [25, 0], ids=("int", "zero int"))
-    def test_success(self, value: int) -> None:
+    @pytest.mark.parametrize("value", (25, 25.0, 0), ids=("int", "float", "zero int"))
+    def test_success(self, value: float) -> None:
         response = EventSourceResponse()
         response.ping_interval = value
         assert response.ping_interval == value
 
     @pytest.mark.parametrize("value", [None, "foo"], ids=("None", "str"))
-    def test_wrong_type(self, value: int) -> None:
+    def test_wrong_type(self, value: float) -> None:
         response = EventSourceResponse()
         with pytest.raises(TypeError) as ctx:
             response.ping_interval = value
 
-        assert ctx.match("ping interval must be int")
+        assert ctx.match("ping interval must be int or float")
 
     def test_negative_int(self) -> None:
         response = EventSourceResponse()
@@ -230,6 +230,31 @@ async def test_ping_reset(
     assert streamed_data == expected
 
 
+async def test_ping_auto_close(aiohttp_client: ClientFixture) -> None:
+    """Test ping task automatically closed on send failure."""
+
+    async def handler(request: web.Request) -> EventSourceResponse:
+        async with sse_response(request) as sse:
+            sse.ping_interval = 999
+
+            request.protocol.force_close()
+            with pytest.raises(ConnectionResetError):
+                await sse.send("never-should-be-delivered")
+
+            assert sse._ping_task is not None
+            assert sse._ping_task.cancelled()
+
+        return sse  # pragma: no cover
+
+    app = web.Application()
+    app.router.add_route("GET", "/", handler)
+
+    client = await aiohttp_client(app)
+
+    async with client.get("/") as response:
+        assert 200 == response.status
+
+
 async def test_context_manager(aiohttp_client: ClientFixture) -> None:
     async def func(request: web.Request) -> web.StreamResponse:
         h = {"X-SSE": "aiohttp_sse"}