Repository: streamlink/streamlink

Issue:
Unexpected FFmpeg version output while running ['/usr/bin/ffmpeg', '-version']
### Checklist

- [X] This is a bug report and not a different kind of issue
- [X] [I have read the contribution guidelines](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink)
- [X] [I have checked the list of open and recently closed bug reports](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22bug%22)
- [X] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Streamlink version

Latest stable release

### Description

FFmpeg binary not validated.
Streamlink version 6.0.0.0 Linux Appimage x86_64
Got this error message:

[stream.ffmpegmux][error] Could not validate FFmpeg!
[stream.ffmpegmux][error] Unexpected FFmpeg version output while running ['/usr/bin/ffmpeg', '-version']
[stream.ffmpegmux][warning] No valid FFmpeg binary was found. See the --ffmpeg-ffmpeg option.
[stream.ffmpegmux][warning] Muxing streams is unsupported! Only a subset of the available streams can be returned!

ffmpeg output:
ffmpeg -version
ffmpeg version 6.0-static https://johnvansickle.com/ffmpeg/  Copyright (c) 2000-2023 the FFmpeg developers
built with gcc 8 (Debian 8.3.0-6)

ffmpeg is in /usr/bin

### Debug log

```text
[utils.l10n][debug] Language code: en_US
[stream.ffmpegmux][error] Could not validate FFmpeg!
[stream.ffmpegmux][error] Unexpected FFmpeg version output while running ['/usr/bin/ffmpeg', '-version']
[stream.ffmpegmux][warning] No valid FFmpeg binary was found. See the --ffmpeg-ffmpeg option.
[stream.ffmpegmux][warning] Muxing streams is unsupported! Only a subset of the available streams can be returned!
[cli][info] Available streams: 234p_alt (worst), 234p, 360p_alt2, 360p_alt, 360p, 540p, 720p_alt, 720p, 1080p (best)
[cli][info] Opening stream: 720p (hls)
[cli][info] Starting player: mpv
[stream.hls][debug] Reloading playlist
[cli][debug] Pre-buffering 8192 bytes
[stream.hls][debug] Segments in this playlist are encrypted
[stream.hls][debug] First Sequence: 22258880; Last Sequence: 22259722
[stream.hls][debug] Start offset: 0; Duration: None; Start Sequence: 22259720; End Sequence: None
[stream.hls][debug] Adding segment 22259720 to queue
[stream.hls][debug] Adding segment 22259721 to queue
[stream.hls][debug] Adding segment 22259722 to queue
[stream.hls][debug] Writing segment 22259720 to output
[stream.hls][debug] Segment 22259720 complete
```


Discussion:

Patch:
diff --git a/src/streamlink/utils/processoutput.py b/src/streamlink/utils/processoutput.py
index 85ed631b..1e14b539 100644
--- a/src/streamlink/utils/processoutput.py
+++ b/src/streamlink/utils/processoutput.py
@@ -48,9 +48,9 @@ class ProcessOutput:
 
         tasks = (
             loop.create_task(ontimeout()),
-            loop.create_task(onexit()),
             loop.create_task(onoutput(self.onstdout, process.stdout)),
             loop.create_task(onoutput(self.onstderr, process.stderr)),
+            loop.create_task(onexit()),
         )
 
         try:
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index ab8189bd..9be687f9 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -15,21 +15,13 @@ from streamlink_cli.output.player import PlayerOutput
 from streamlink_cli.utils import find_default_player
 
 
-_printable_re = re.compile(r"[{0}]".format(printable))
-_option_re = re.compile(r"""
-    (?P<name>[A-z-]+) # A option name, valid characters are A to z and dash.
-    \s*
-    (?P<op>=)? # Separating the option and the value with a equals sign is
-               # common, but optional.
-    \s*
-    (?P<value>.*) # The value, anything goes.
-""", re.VERBOSE)
-
-
 class ArgumentParser(argparse.ArgumentParser):
     # noinspection PyUnresolvedReferences,PyProtectedMember
     NESTED_ARGUMENT_GROUPS: Dict[Optional[argparse._ArgumentGroup], List[argparse._ArgumentGroup]]
 
+    _RE_PRINTABLE = re.compile(fr"[{re.escape(printable)}]")
+    _RE_OPTION = re.compile(r"^(?P<name>[A-Za-z0-9-]+)(?:(?P<op>\s*=\s*|\s+)(?P<value>.*))?$")
+
     def __init__(self, *args, **kwargs):
         self.NESTED_ARGUMENT_GROUPS = {}
         super().__init__(*args, **kwargs)
@@ -51,21 +43,22 @@ class ArgumentParser(argparse.ArgumentParser):
     def convert_arg_line_to_args(self, line):
         # Strip any non-printable characters that might be in the
         # beginning of the line (e.g. Unicode BOM marker).
-        match = _printable_re.search(line)
+        match = self._RE_PRINTABLE.search(line)
         if not match:
             return
         line = line[match.start():].strip()
 
         # Skip lines that do not start with a valid option (e.g. comments)
-        option = _option_re.match(line)
+        option = self._RE_OPTION.match(line)
         if not option:
             return
 
-        name, value = option.group("name", "value")
-        if name and value:
-            yield f"--{name}={value}"
-        elif name:
-            yield f"--{name}"
+        name, op, value = option.group("name", "op", "value")
+        prefix = self.prefix_chars[0] if len(name) == 1 else self.prefix_chars[0] * 2
+        if value or op:
+            yield f"{prefix}{name}={value}"
+        else:
+            yield f"{prefix}{name}"
 
     def _match_argument(self, action, arg_strings_pattern):
         # - https://github.com/streamlink/streamlink/issues/971


Test patch:
diff --git a/tests/cli/test_argparser.py b/tests/cli/test_argparser.py
index d7b0893b..c157cecc 100644
--- a/tests/cli/test_argparser.py
+++ b/tests/cli/test_argparser.py
@@ -1,4 +1,6 @@
+import argparse
 from argparse import ArgumentParser, Namespace
+from pathlib import Path
 from typing import Any, List
 from unittest.mock import Mock
 
@@ -14,6 +16,65 @@ def parser():
     return build_parser()
 
 
+class TestConfigFileArguments:
+    @pytest.fixture()
+    def parsed(self, request: pytest.FixtureRequest, parser: argparse.ArgumentParser, tmp_path: Path):
+        content = "\n".join([
+            "",
+            " ",
+            "# comment",
+            "! comment",
+            "invalid_option_format",
+            *getattr(request, "param", []),
+        ])
+
+        config = tmp_path / "config"
+        with config.open("w") as fd:
+            fd.write(content)
+
+        return parser.parse_args([f"@{config}"])
+
+    @pytest.mark.parametrize("parsed", [[]], indirect=True)
+    def test_nooptions(self, parsed: Namespace):
+        assert parsed.ipv4 is None
+        assert parsed.player_fifo is False
+        assert parsed.player_args == ""
+        assert parsed.title is None
+
+    @pytest.mark.parametrize("parsed", [
+        pytest.param(["4"], id="shorthand name"),
+        pytest.param(["ipv4"], id="full name"),
+    ], indirect=True)
+    def test_alphanumerical(self, parsed: Namespace):
+        assert parsed.ipv4 is True
+
+    @pytest.mark.parametrize("parsed", [
+        pytest.param(["n"], id="shorthand name"),
+        pytest.param(["player-fifo"], id="full name"),
+    ], indirect=True)
+    def test_withoutvalue(self, parsed: Namespace):
+        assert parsed.player_fifo is True
+
+    @pytest.mark.parametrize("parsed", [
+        pytest.param(["a=foo bar "], id="shorthand name with operator"),
+        pytest.param(["a = foo bar "], id="shorthand name with operator and surrounding whitespace"),
+        pytest.param(["a   foo bar "], id="shorthand name without operator"),
+        pytest.param(["player-args=foo bar "], id="full name with operator"),
+        pytest.param(["player-args = foo bar "], id="full name with operator and surrounding whitespace"),
+        pytest.param(["player-args   foo bar "], id="full name without operator"),
+    ], indirect=True)
+    def test_withvalue(self, parsed: Namespace):
+        assert parsed.player_args == "foo bar"
+
+    @pytest.mark.parametrize("parsed", [
+        pytest.param(["title="], id="operator"),
+        pytest.param(["title ="], id="operator with leading whitespace"),
+        pytest.param(["title = "], id="operator with surrounding whitespace"),
+    ], indirect=True)
+    def test_emptyvalue(self, parsed: Namespace):
+        assert parsed.title == ""
+
+
 @pytest.mark.filterwarnings("ignore")
 @pytest.mark.parametrize(("argv", "option", "expected"), [
     pytest.param(
diff --git a/tests/utils/test_processoutput.py b/tests/utils/test_processoutput.py
index f7cc2654..481fa11b 100644
--- a/tests/utils/test_processoutput.py
+++ b/tests/utils/test_processoutput.py
@@ -217,3 +217,23 @@ async def test_onoutput_exception(event_loop: asyncio.BaseEventLoop, processoutp
     assert processoutput.onstdout.call_args_list == [call(0, "foo")]
     assert processoutput.onstderr.call_args_list == []
     assert mock_process.kill.called
+
+
+@pytest.mark.asyncio()
+async def test_exit_before_onoutput(event_loop: asyncio.BaseEventLoop, processoutput: FakeProcessOutput, mock_process: Mock):
+    # resolve process.wait() in the onexit task immediately
+    mock_process.wait = AsyncMock(return_value=0)
+
+    # add some data to stdout, but don't actually interpret it in onstdout
+    mock_process.stdout.append(b"foo")
+    processoutput.onstdout.return_value = True
+
+    # the result of the `done` future should have already been set by the onoutput task
+    processoutput.onexit.return_value = False
+
+    result = await processoutput._run()
+
+    assert result is True
+    assert processoutput.onexit.called
+    assert processoutput.onstdout.called
+    assert mock_process.kill.called