Repository: meltano/meltano (language: python)

Issue:
bug: `UnicodeDecodeError` when processing plugin's output for logging
### Meltano Version

3.3.0

### Python Version

NA

### Bug scope

CLI (options, error messages, logging, etc.)

### Operating System

Windows, probably

### Description

This line https://github.com/meltano/meltano/blob/42f0fc6895da3265d0fede67fae09cd37b7843f1/src/meltano/core/logging/utils.py#L208

could probably be made safer by passing `errors="replace"`[^1] to the `.decode()` method.

[^1]: https://docs.python.org/3.12/library/stdtypes.html#bytes.decode

### Code

_No response_
bug: `UnicodeDecodeError` when processing plugin's output for logging
### Meltano Version

3.3.0

### Python Version

NA

### Bug scope

CLI (options, error messages, logging, etc.)

### Operating System

Windows, probably

### Description

This line https://github.com/meltano/meltano/blob/42f0fc6895da3265d0fede67fae09cd37b7843f1/src/meltano/core/logging/utils.py#L208

could probably be made safer by passing `errors="replace"`[^1] to the `.decode()` method.

[^1]: https://docs.python.org/3.12/library/stdtypes.html#bytes.decode

### Code

_No response_


Discussion / hints:
Hi. I am a Django developer with six years of experience. I would like to start contributing to `meltano` starting with this issue if there is no problem then. Would it be possible to assign this to me?
Go for it @nkclemson!
Does anyone know the error log text to test this bug? I can add normal Unicode text and use `.decode('utf-8', errors='ignore')` in the code, but I want to make sure this is the reason the bug was raised.
Can't find the original Slack message, but I'm sure it was a latin-1 character like `í`:

```python
>>> s = b"\xed"
>>> s
b'\xed'
>>> s.decode()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xed in position 0: unexpected end of data
>>> s.decode("latin-1")
'í'
>>> s.decode(errors="replace")
'�'
```
Hi. I am a Django developer with six years of experience. I would like to start contributing to `meltano` starting with this issue if there is no problem then. Would it be possible to assign this to me?
Go for it @nkclemson!
Does anyone know the error log text to test this bug? I can add normal Unicode text and use `.decode('utf-8', errors='ignore')` in the code, but I want to make sure this is the reason the bug was raised.
Can't find the original Slack message, but I'm sure it was a latin-1 character like `í`:

```python
>>> s = b"\xed"
>>> s
b'\xed'
>>> s.decode()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xed in position 0: unexpected end of data
>>> s.decode("latin-1")
'í'
>>> s.decode(errors="replace")
'�'
```

Fix patch:
diff --git a/src/meltano/core/logging/utils.py b/src/meltano/core/logging/utils.py
index fe0d8b2d0f..3cf05f49fc 100644
--- a/src/meltano/core/logging/utils.py
+++ b/src/meltano/core/logging/utils.py
@@ -205,7 +205,7 @@ async def _write_line_writer(writer: SubprocessOutputWriter, line: bytes):
             await writer.wait_closed()
             return False
     else:
-        writer.writeline(line.decode())
+        writer.writeline(line.decode(errors="replace"))
 
     return True
 
@@ -214,7 +214,7 @@ async def capture_subprocess_output(
     reader: asyncio.StreamReader | None,
     *line_writers: SubprocessOutputWriter,
 ) -> None:
-    """Capture in real time the output stream of a suprocess that is run async.
+    """Capture in real time the output stream of a subprocess that is run async.
 
     The stream has been set to asyncio.subprocess.PIPE and is provided using
     reader to this function.


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 6edfe17861..1e5c31df66 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -289,6 +289,8 @@ jobs:
 
     - name: Upload coverage report
       with:
+        fail_ci_if_error: true
+        files: ./coverage.xml
         token: ${{ secrets.CODECOV_TOKEN }}
       uses: codecov/codecov-action@v4.0.1
 
diff --git a/tests/meltano/core/logging/test_logging_utils.py b/tests/meltano/core/logging/test_logging_utils.py
new file mode 100644
index 0000000000..7ab5fe5832
--- /dev/null
+++ b/tests/meltano/core/logging/test_logging_utils.py
@@ -0,0 +1,33 @@
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from meltano.core.logging.utils import capture_subprocess_output
+
+
+class AsyncReader(asyncio.StreamReader):
+    def __init__(self, lines: list[bytes]):
+        self.lines = lines
+
+    def at_eof(self) -> bool:
+        return not self.lines
+
+    async def readline(self) -> bytes:
+        return self.lines.pop(0) if self.lines else b""
+
+
+@pytest.mark.asyncio()
+async def test_capture_subprocess_output():
+    INPUT_LINES = [b"LINE\n", b"LINE 2\n", b"\xed\n"]
+    OUTPUT_LINES = []
+
+    class LineWriter:
+        def writeline(self, line: str):
+            OUTPUT_LINES.append(line)
+
+    reader = AsyncReader(INPUT_LINES)
+
+    await capture_subprocess_output(reader, LineWriter())
+    assert ["LINE\n", "LINE 2\n", "�\n"] == OUTPUT_LINES