Merge pull request #589 from arabcoders/dev
feat(console): make terminal window more durable
This commit is contained in:
commit
8729f030c7
18 changed files with 3539 additions and 531 deletions
4
.github/workflows/native-build.yml
vendored
4
.github/workflows/native-build.yml
vendored
|
|
@ -113,7 +113,9 @@ jobs:
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
pip install uv
|
pip install uv
|
||||||
uv venv --system-site-packages --relocatable
|
if [ ! -d .venv ]; then
|
||||||
|
uv venv --system-site-packages --relocatable
|
||||||
|
fi
|
||||||
uv sync --link-mode=copy --active --extra installer
|
uv sync --link-mode=copy --active --extra installer
|
||||||
|
|
||||||
- name: Cache bun installation
|
- name: Cache bun installation
|
||||||
|
|
|
||||||
124
API.md
124
API.md
|
|
@ -97,6 +97,10 @@ This document describes the available endpoints and their usage. All endpoints r
|
||||||
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
|
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
|
||||||
- [GET /api/system/configuration](#get-apisystemconfiguration)
|
- [GET /api/system/configuration](#get-apisystemconfiguration)
|
||||||
- [POST /api/system/terminal](#post-apisystemterminal)
|
- [POST /api/system/terminal](#post-apisystemterminal)
|
||||||
|
- [GET /api/system/terminal/active](#get-apisystemterminalactive)
|
||||||
|
- [GET /api/system/terminal/{session\_id}](#get-apisystemterminalsession_id)
|
||||||
|
- [DELETE /api/system/terminal/{session\_id}](#delete-apisystemterminalsession_id)
|
||||||
|
- [GET /api/system/terminal/{session\_id}/stream](#get-apisystemterminalsession_idstream)
|
||||||
- [POST /api/system/pause](#post-apisystempause)
|
- [POST /api/system/pause](#post-apisystempause)
|
||||||
- [POST /api/system/resume](#post-apisystemresume)
|
- [POST /api/system/resume](#post-apisystemresume)
|
||||||
- [POST /api/system/shutdown](#post-apisystemshutdown)
|
- [POST /api/system/shutdown](#post-apisystemshutdown)
|
||||||
|
|
@ -2476,7 +2480,7 @@ or an error:
|
||||||
---
|
---
|
||||||
|
|
||||||
### POST /api/system/terminal
|
### POST /api/system/terminal
|
||||||
**Purpose**: Stream yt-dlp CLI output via Server-Sent Events (SSE). Requires `YTP_CONSOLE_ENABLED=true`.
|
**Purpose**: Start a yt-dlp terminal session. Requires `YTP_CONSOLE_ENABLED=true`.
|
||||||
|
|
||||||
**Body**:
|
**Body**:
|
||||||
```json
|
```json
|
||||||
|
|
@ -2485,9 +2489,118 @@ or an error:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "3a8c5f7e2d3b4a8f9c0d1e2f3a4b5c6d",
|
||||||
|
"command": "--help",
|
||||||
|
"status": "starting",
|
||||||
|
"created_at": 1713000000.0,
|
||||||
|
"started_at": 1713000000.0,
|
||||||
|
"finished_at": null,
|
||||||
|
"expires_at": null,
|
||||||
|
"exit_code": null,
|
||||||
|
"last_sequence": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes**:
|
||||||
|
- Starts the command in an application-owned background task.
|
||||||
|
- Only one active terminal session is allowed at a time.
|
||||||
|
- The command continues running if the frontend disconnects or reloads.
|
||||||
|
|
||||||
|
- `403 Forbidden` if console is disabled.
|
||||||
|
- `400 Bad Request` if the request body is invalid.
|
||||||
|
- `409 Conflict` if another terminal session is already active.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/system/terminal/active
|
||||||
|
**Purpose**: Return the currently active terminal session metadata, or `null` if no session is active.
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
null
|
||||||
|
```
|
||||||
|
or
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "3a8c5f7e2d3b4a8f9c0d1e2f3a4b5c6d",
|
||||||
|
"command": "--help",
|
||||||
|
"status": "running",
|
||||||
|
"created_at": 1713000000.0,
|
||||||
|
"started_at": 1713000000.0,
|
||||||
|
"finished_at": null,
|
||||||
|
"expires_at": null,
|
||||||
|
"exit_code": null,
|
||||||
|
"last_sequence": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `403 Forbidden` if console is disabled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/system/terminal/{session_id}
|
||||||
|
**Purpose**: Return metadata for a specific terminal session while it is active or still within the replay/drain window.
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "3a8c5f7e2d3b4a8f9c0d1e2f3a4b5c6d",
|
||||||
|
"command": "--help",
|
||||||
|
"status": "completed",
|
||||||
|
"created_at": 1713000000.0,
|
||||||
|
"started_at": 1713000000.0,
|
||||||
|
"finished_at": 1713000004.0,
|
||||||
|
"expires_at": 1713000034.0,
|
||||||
|
"exit_code": 0,
|
||||||
|
"last_sequence": 15
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `403 Forbidden` if console is disabled.
|
||||||
|
- `404 Not Found` if the session does not exist or has already expired.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DELETE /api/system/terminal/{session_id}
|
||||||
|
**Purpose**: Request cancellation for the active terminal session.
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Terminal session cancellation requested.",
|
||||||
|
"session_id": "3a8c5f7e2d3b4a8f9c0d1e2f3a4b5c6d"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes**:
|
||||||
|
- This only applies to the currently active session.
|
||||||
|
- The client should stay attached to the stream to receive the final `close` event and refreshed terminal status.
|
||||||
|
- Cancelled sessions finalize as `interrupted` and remain replayable until the drain window expires.
|
||||||
|
|
||||||
|
- `403 Forbidden` if console is disabled.
|
||||||
|
- `404 Not Found` if the session does not exist or has already expired.
|
||||||
|
- `409 Conflict` if the session exists but is no longer active.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/system/terminal/{session_id}/stream
|
||||||
|
**Purpose**: Replay a terminal session transcript over SSE and tail live output when the session is still running.
|
||||||
|
|
||||||
|
**Query Parameters**:
|
||||||
|
- `since` (optional): Resume after the provided integer event id.
|
||||||
|
|
||||||
|
**Headers**:
|
||||||
|
- `Last-Event-ID` (optional): Resume after the provided integer event id.
|
||||||
|
|
||||||
|
If both `since` and `Last-Event-ID` are present, the larger value is used.
|
||||||
|
|
||||||
**Response**:
|
**Response**:
|
||||||
- `Content-Type: text/event-stream`
|
- `Content-Type: text/event-stream`
|
||||||
- Emits `output` events for stdout/stderr and a final `close` event when the process exits.
|
- Replays transcript events with monotonic integer SSE `id` values.
|
||||||
|
- Emits `output` events for stdout/stderr and a final `close` event when available.
|
||||||
|
|
||||||
**Event Payloads**:
|
**Event Payloads**:
|
||||||
```json
|
```json
|
||||||
|
|
@ -2497,8 +2610,13 @@ or an error:
|
||||||
{ "exitcode": 0 }
|
{ "exitcode": 0 }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Notes**:
|
||||||
|
- Replay/restore works while the session is still running or until the finished session expires.
|
||||||
|
- Finished sessions are removed lazily after the transcript drain window elapses.
|
||||||
|
|
||||||
- `403 Forbidden` if console is disabled.
|
- `403 Forbidden` if console is disabled.
|
||||||
- `400 Bad Request` if the request body is invalid.
|
- `400 Bad Request` if the replay cursor is invalid.
|
||||||
|
- `404 Not Found` if the session does not exist or has already expired.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
98
app/features/ytdlp/patches.py
Normal file
98
app/features/ytdlp/patches.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
LOG: logging.Logger = logging.getLogger("ytdlp.utils")
|
||||||
|
|
||||||
|
|
||||||
|
def patch_metadataparser() -> None:
|
||||||
|
"""
|
||||||
|
Patches yt_dlp MetadataParserPP action to handle subprocess pickling issues.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from yt_dlp.postprocessor.metadataparser import MetadataParserPP
|
||||||
|
from yt_dlp.utils import Namespace
|
||||||
|
except Exception as exc:
|
||||||
|
LOG.warning(f"Unable to import yt_dlp metadata parser for patching: {exc!s}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if getattr(MetadataParserPP.Actions, "_ytptube_patched", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
class _ActionNS(Namespace):
|
||||||
|
_ACTIONS_STR: list[str] = []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_name(func) -> str | None:
|
||||||
|
if not callable(func):
|
||||||
|
return None
|
||||||
|
|
||||||
|
target = getattr(func, "__func__", func)
|
||||||
|
module_name = getattr(target, "__module__", None)
|
||||||
|
qual_name = getattr(target, "__qualname__", getattr(target, "__name__", None))
|
||||||
|
|
||||||
|
return f"{module_name}.{qual_name}" if module_name and qual_name else None
|
||||||
|
|
||||||
|
def __contains__(self, candidate: object) -> bool:
|
||||||
|
if candidate in self.__dict__.values():
|
||||||
|
return True
|
||||||
|
|
||||||
|
if func_name := _ActionNS._get_name(candidate):
|
||||||
|
if len(_ActionNS._ACTIONS_STR) < 1:
|
||||||
|
_ActionNS._ACTIONS_STR.extend(
|
||||||
|
[value for value in (_ActionNS._get_name(value) for value in self.__dict__.values()) if value]
|
||||||
|
)
|
||||||
|
|
||||||
|
return func_name in _ActionNS._ACTIONS_STR
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
actions_dict: dict[str, Any] = dict(MetadataParserPP.Actions.items_)
|
||||||
|
MetadataParserPP.Actions = _ActionNS(**actions_dict)
|
||||||
|
MetadataParserPP.Actions._ytptube_patched = True
|
||||||
|
LOG.debug("MetadataParserPP action namespace patch applied successfully.")
|
||||||
|
|
||||||
|
|
||||||
|
def patch_windows_popen_wait() -> None:
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from yt_dlp.utils import Popen
|
||||||
|
except Exception as exc:
|
||||||
|
LOG.warning(f"Unable to import yt_dlp Popen for patching: {exc!s}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if getattr(Popen, "_ytptube_wait_patched", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
original_wait = Popen.wait
|
||||||
|
|
||||||
|
# Windows subprocess waits can swallow the synthetic interrupt we use to
|
||||||
|
# stop live downloads, especially while yt-dlp is blocked on ffmpeg.
|
||||||
|
def interruptible_wait(self, timeout=None):
|
||||||
|
if timeout is not None:
|
||||||
|
return original_wait(self, timeout=timeout)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
return original_wait(self, timeout=0.1)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
continue
|
||||||
|
|
||||||
|
Popen.wait = interruptible_wait
|
||||||
|
Popen._ytptube_wait_patched = True
|
||||||
|
LOG.debug("yt_dlp Popen.wait Windows patch applied successfully.")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_ytdlp_patches() -> None:
|
||||||
|
try:
|
||||||
|
patch_metadataparser()
|
||||||
|
except Exception as exc:
|
||||||
|
LOG.debug("Metadata parser patch failed to apply: %s", exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
patch_windows_popen_wait()
|
||||||
|
except Exception as exc:
|
||||||
|
LOG.debug("Windows Popen wait patch failed to apply: %s", exc)
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
|
from app.features.ytdlp.patches import patch_windows_popen_wait
|
||||||
from app.features.ytdlp.utils import _DATA
|
from app.features.ytdlp.utils import _DATA
|
||||||
from app.features.ytdlp.ytdlp import YTDLP, _ArchiveProxy, ytdlp_options
|
from app.features.ytdlp.ytdlp import YTDLP, _ArchiveProxy, ytdlp_options
|
||||||
|
|
||||||
|
|
@ -145,6 +146,61 @@ class TestYTDLP:
|
||||||
assert isinstance(ytdlp.archive, _ArchiveProxy)
|
assert isinstance(ytdlp.archive, _ArchiveProxy)
|
||||||
assert not ytdlp.archive
|
assert not ytdlp.archive
|
||||||
|
|
||||||
|
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
|
||||||
|
def test_init_patches_windows_popen_wait_once(self, mock_super_init) -> None:
|
||||||
|
mock_super_init.return_value = None
|
||||||
|
|
||||||
|
class FakePopen:
|
||||||
|
def wait(self, timeout=None):
|
||||||
|
return timeout
|
||||||
|
|
||||||
|
with patch("app.features.ytdlp.patches.sys.platform", "win32"):
|
||||||
|
with patch("yt_dlp.utils.Popen", FakePopen):
|
||||||
|
YTDLP(params={})
|
||||||
|
|
||||||
|
assert getattr(FakePopen, "_ytptube_wait_patched", False) is True
|
||||||
|
|
||||||
|
def test_windows_wait_patch_uses_polling_for_blocking_wait(self) -> None:
|
||||||
|
calls: list[float | None] = []
|
||||||
|
|
||||||
|
class FakePopen:
|
||||||
|
_ytptube_wait_patched = False
|
||||||
|
|
||||||
|
def wait(self, timeout=None):
|
||||||
|
calls.append(timeout)
|
||||||
|
if len(calls) < 3:
|
||||||
|
raise TimeoutError
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with patch("app.features.ytdlp.patches.sys.platform", "win32"):
|
||||||
|
with (
|
||||||
|
patch("yt_dlp.utils.Popen", FakePopen),
|
||||||
|
patch("app.features.ytdlp.patches.subprocess.TimeoutExpired", TimeoutError),
|
||||||
|
):
|
||||||
|
patch_windows_popen_wait()
|
||||||
|
result = FakePopen().wait()
|
||||||
|
|
||||||
|
assert result == 0
|
||||||
|
assert calls == [0.1, 0.1, 0.1]
|
||||||
|
|
||||||
|
def test_windows_wait_patch_preserves_explicit_timeout(self) -> None:
|
||||||
|
calls: list[float | None] = []
|
||||||
|
|
||||||
|
class FakePopen:
|
||||||
|
_ytptube_wait_patched = False
|
||||||
|
|
||||||
|
def wait(self, timeout=None):
|
||||||
|
calls.append(timeout)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with patch("app.features.ytdlp.patches.sys.platform", "win32"):
|
||||||
|
with patch("yt_dlp.utils.Popen", FakePopen):
|
||||||
|
patch_windows_popen_wait()
|
||||||
|
result = FakePopen().wait(timeout=5)
|
||||||
|
|
||||||
|
assert result == 0
|
||||||
|
assert calls == [5]
|
||||||
|
|
||||||
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL._delete_downloaded_files")
|
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL._delete_downloaded_files")
|
||||||
def test_delete_downloaded_files_skips_when_interrupted(self, mock_super_delete) -> None:
|
def test_delete_downloaded_files_skips_when_interrupted(self, mock_super_delete) -> None:
|
||||||
"""Test _delete_downloaded_files skips cleanup when _interrupted is True."""
|
"""Test _delete_downloaded_files skips cleanup when _interrupted is True."""
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from email.utils import formatdate
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from app.features.ytdlp.patches import apply_ytdlp_patches
|
||||||
from app.features.ytdlp.ytdlp import YTDLP
|
from app.features.ytdlp.ytdlp import YTDLP
|
||||||
from app.library.Utils import merge_dict, timed_lru_cache
|
from app.library.Utils import merge_dict, timed_lru_cache
|
||||||
|
|
||||||
|
|
@ -143,52 +144,6 @@ class LogWrapper:
|
||||||
self._log(logging.CRITICAL, msg, *args, **kwargs)
|
self._log(logging.CRITICAL, msg, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def patch_metadataparser() -> None:
|
|
||||||
"""
|
|
||||||
Patches yt_dlp MetadataParserPP action to handle subprocess pickling issues.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from yt_dlp.postprocessor.metadataparser import MetadataParserPP
|
|
||||||
from yt_dlp.utils import Namespace
|
|
||||||
except Exception as exc:
|
|
||||||
LOG.warning(f"Unable to import yt_dlp metadata parser for patching: {exc!s}")
|
|
||||||
return
|
|
||||||
|
|
||||||
if getattr(MetadataParserPP.Actions, "_ytptube_patched", False):
|
|
||||||
return
|
|
||||||
|
|
||||||
class _ActionNS(Namespace):
|
|
||||||
_ACTIONS_STR: list[str] = []
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_name(func) -> str | None:
|
|
||||||
if not callable(func):
|
|
||||||
return None
|
|
||||||
|
|
||||||
target = getattr(func, "__func__", func)
|
|
||||||
module_name = getattr(target, "__module__", None)
|
|
||||||
qual_name = getattr(target, "__qualname__", getattr(target, "__name__", None))
|
|
||||||
|
|
||||||
return f"{module_name}.{qual_name}" if module_name and qual_name else None
|
|
||||||
|
|
||||||
def __contains__(self, candidate: object) -> bool:
|
|
||||||
if candidate in self.__dict__.values():
|
|
||||||
return True
|
|
||||||
|
|
||||||
if func_name := _ActionNS._get_name(candidate):
|
|
||||||
if len(_ActionNS._ACTIONS_STR) < 1:
|
|
||||||
_ActionNS._ACTIONS_STR.extend([_ActionNS._get_name(value) for value in self.__dict__.values()])
|
|
||||||
|
|
||||||
return func_name in _ActionNS._ACTIONS_STR
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
actions_dict: dict[str, Any] = dict(MetadataParserPP.Actions.items_)
|
|
||||||
MetadataParserPP.Actions = _ActionNS(**actions_dict)
|
|
||||||
MetadataParserPP.Actions._ytptube_patched = True
|
|
||||||
LOG.debug("MetadataParserPP action namespace patch applied successfully.")
|
|
||||||
|
|
||||||
|
|
||||||
def arg_converter(
|
def arg_converter(
|
||||||
args: str,
|
args: str,
|
||||||
level: int | bool | None = None,
|
level: int | bool | None = None,
|
||||||
|
|
@ -222,10 +177,7 @@ def arg_converter(
|
||||||
finally:
|
finally:
|
||||||
yt_dlp.options.create_parser = create_parser
|
yt_dlp.options.create_parser = create_parser
|
||||||
|
|
||||||
try:
|
apply_ytdlp_patches()
|
||||||
patch_metadataparser()
|
|
||||||
except Exception as exc:
|
|
||||||
LOG.debug("Metadata parser patch failed to apply: %s", exc)
|
|
||||||
|
|
||||||
default_opts = _default_opts([]).ydl_opts
|
default_opts = _default_opts([]).ydl_opts
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from typing import Any
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
from yt_dlp.utils import make_archive_id
|
from yt_dlp.utils import make_archive_id
|
||||||
|
|
||||||
|
from app.features.ytdlp.patches import apply_ytdlp_patches
|
||||||
from app.library.cf_solver_handler import set_cf_handler
|
from app.library.cf_solver_handler import set_cf_handler
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -52,13 +53,15 @@ class YTDLP(yt_dlp.YoutubeDL):
|
||||||
_registered = False
|
_registered = False
|
||||||
|
|
||||||
def __init__(self, params=None, auto_init=True):
|
def __init__(self, params=None, auto_init=True):
|
||||||
|
apply_ytdlp_patches()
|
||||||
|
|
||||||
# Avoid yt-dlp preloading the archive file by stripping the param first
|
# Avoid yt-dlp preloading the archive file by stripping the param first
|
||||||
orig_file = None
|
orig_file = None
|
||||||
patched_params = None
|
patched_params: dict[str, Any] | None = None
|
||||||
if params is not None:
|
if params is not None:
|
||||||
try:
|
try:
|
||||||
orig_file: str | None = params.get("download_archive")
|
orig_file: str | None = params.get("download_archive")
|
||||||
patched_params: dict = dict(params)
|
patched_params = dict(params)
|
||||||
if "download_archive" in patched_params:
|
if "download_archive" in patched_params:
|
||||||
patched_params.pop("download_archive", None)
|
patched_params.pop("download_archive", None)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
698
app/library/TerminalSessionManager.py
Normal file
698
app/library/TerminalSessionManager.py
Normal file
|
|
@ -0,0 +1,698 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import errno
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from app.library.config import Config
|
||||||
|
from app.library.Services import Services
|
||||||
|
from app.library.Singleton import Singleton
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from asyncio.events import AbstractEventLoop
|
||||||
|
from asyncio.subprocess import Process
|
||||||
|
|
||||||
|
from aiohttp.web import Request
|
||||||
|
|
||||||
|
LOG: logging.Logger = logging.getLogger("terminal_manager")
|
||||||
|
|
||||||
|
ACTIVE_FILE_NAME = "active.json"
|
||||||
|
METADATA_FILE_NAME = "metadata.json"
|
||||||
|
TRANSCRIPT_FILE_NAME = "transcript.jsonl"
|
||||||
|
DEFAULT_DRAIN_TTL = 30.0
|
||||||
|
DEFAULT_KEEPALIVE_INTERVAL = 15.0
|
||||||
|
DEFAULT_SHUTDOWN_TIMEOUT = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalSessionConflictError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ActiveTerminalSession:
|
||||||
|
session_id: str
|
||||||
|
task: asyncio.Task[None]
|
||||||
|
process: Process | None = None
|
||||||
|
subscribers: set[asyncio.Queue[dict[str, Any] | None]] = field(default_factory=set)
|
||||||
|
interrupted: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalSessionManager(metaclass=Singleton):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.config: Config = Config.get_instance()
|
||||||
|
self.root_path: Path = Path(self.config.config_path) / "runtime" / "terminal"
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._active: ActiveTerminalSession | None = None
|
||||||
|
self._drain_ttl: float = DEFAULT_DRAIN_TTL
|
||||||
|
self._keepalive_interval: float = DEFAULT_KEEPALIVE_INTERVAL
|
||||||
|
self._shutdown_timeout: float = DEFAULT_SHUTDOWN_TIMEOUT
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_instance() -> TerminalSessionManager:
|
||||||
|
return TerminalSessionManager()
|
||||||
|
|
||||||
|
def attach(self, app: web.Application) -> None:
|
||||||
|
self._ensure_root()
|
||||||
|
Services.get_instance().add("terminal_manager", self)
|
||||||
|
app.on_startup.append(self.on_startup)
|
||||||
|
app.on_shutdown.append(self.on_shutdown)
|
||||||
|
|
||||||
|
async def on_startup(self, _: web.Application) -> None:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
async def on_shutdown(self, _: web.Application) -> None:
|
||||||
|
session_id: str | None = None
|
||||||
|
task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
runtime = self._active
|
||||||
|
if runtime is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
runtime.interrupted = True
|
||||||
|
session_id = runtime.session_id
|
||||||
|
task = runtime.task
|
||||||
|
|
||||||
|
self._signal_process(runtime.process)
|
||||||
|
if runtime.process is None and task.done() is False:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
for subscriber in list(runtime.subscribers):
|
||||||
|
subscriber.put_nowait(None)
|
||||||
|
|
||||||
|
assert session_id is not None
|
||||||
|
assert task is not None
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(asyncio.shield(task), timeout=self._shutdown_timeout)
|
||||||
|
return
|
||||||
|
except TimeoutError:
|
||||||
|
async with self._lock:
|
||||||
|
runtime = self._active
|
||||||
|
if runtime is not None and runtime.session_id == session_id:
|
||||||
|
self._signal_process(runtime.process, force=True)
|
||||||
|
if task.done() is False:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(asyncio.shield(task), timeout=self._shutdown_timeout)
|
||||||
|
return
|
||||||
|
except TimeoutError:
|
||||||
|
LOG.warning("Terminal session '%s' did not finish during shutdown.", session_id)
|
||||||
|
|
||||||
|
await self._force_interrupt_session(session_id)
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
self._ensure_root()
|
||||||
|
self._cleanup_expired_sessions(time.time())
|
||||||
|
self._recover_orphaned_active_session(time.time())
|
||||||
|
|
||||||
|
async def cleanup(self) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
self._cleanup_expired_sessions(time.time())
|
||||||
|
|
||||||
|
async def create_session(self, command: str) -> dict[str, Any]:
|
||||||
|
async with self._lock:
|
||||||
|
now = time.time()
|
||||||
|
self._cleanup_expired_sessions(now)
|
||||||
|
|
||||||
|
active_session = self._get_active_session_locked()
|
||||||
|
if active_session is not None:
|
||||||
|
msg = "A terminal session is already active."
|
||||||
|
raise TerminalSessionConflictError(msg)
|
||||||
|
|
||||||
|
session_id = uuid.uuid4().hex
|
||||||
|
session_dir = self._session_dir(session_id)
|
||||||
|
session_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"command": command,
|
||||||
|
"status": "starting",
|
||||||
|
"created_at": now,
|
||||||
|
"started_at": now,
|
||||||
|
"finished_at": None,
|
||||||
|
"expires_at": None,
|
||||||
|
"exit_code": None,
|
||||||
|
"last_sequence": 0,
|
||||||
|
}
|
||||||
|
self._write_json(self._metadata_path(session_id), metadata)
|
||||||
|
self._transcript_path(session_id).touch(exist_ok=True)
|
||||||
|
self._set_active_marker(session_id)
|
||||||
|
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self._run_session(session_id=session_id, command=command), name=f"terminal_{session_id}"
|
||||||
|
)
|
||||||
|
self._active = ActiveTerminalSession(session_id=session_id, task=task)
|
||||||
|
return dict(metadata)
|
||||||
|
|
||||||
|
async def get_active_session(self) -> dict[str, Any] | None:
|
||||||
|
async with self._lock:
|
||||||
|
self._cleanup_expired_sessions(time.time())
|
||||||
|
metadata = self._get_active_session_locked()
|
||||||
|
return None if metadata is None else dict(metadata)
|
||||||
|
|
||||||
|
async def get_session(self, session_id: str) -> dict[str, Any] | None:
|
||||||
|
async with self._lock:
|
||||||
|
self._cleanup_expired_sessions(time.time())
|
||||||
|
metadata = self._load_metadata(session_id)
|
||||||
|
return None if metadata is None else dict(metadata)
|
||||||
|
|
||||||
|
async def cancel_session(self, session_id: str) -> dict[str, Any]:
|
||||||
|
async with self._lock:
|
||||||
|
self._cleanup_expired_sessions(time.time())
|
||||||
|
|
||||||
|
metadata = self._load_metadata(session_id)
|
||||||
|
if metadata is None:
|
||||||
|
msg = f"Unknown terminal session '{session_id}'."
|
||||||
|
raise FileNotFoundError(msg)
|
||||||
|
|
||||||
|
runtime = self._active
|
||||||
|
if runtime is None or runtime.session_id != session_id:
|
||||||
|
msg = "Terminal session is not active."
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
runtime.interrupted = True
|
||||||
|
self._signal_process(runtime.process)
|
||||||
|
if runtime.process is None and runtime.task.done() is False:
|
||||||
|
runtime.task.cancel()
|
||||||
|
|
||||||
|
return dict(metadata)
|
||||||
|
|
||||||
|
async def stream_session(self, session_id: str, request: Request) -> web.StreamResponse:
|
||||||
|
since = self._parse_since(request)
|
||||||
|
|
||||||
|
response = web.StreamResponse(
|
||||||
|
status=web.HTTPOk.status_code,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await response.prepare(request)
|
||||||
|
|
||||||
|
queue: asyncio.Queue[dict[str, Any] | None] | None = None
|
||||||
|
last_sent = since
|
||||||
|
|
||||||
|
try:
|
||||||
|
replay_events = self._read_transcript(session_id=session_id, since=since)
|
||||||
|
for event in replay_events:
|
||||||
|
if await self._emit_sse(request=request, response=response, event=event) is False:
|
||||||
|
return response
|
||||||
|
last_sent = event["seq"]
|
||||||
|
|
||||||
|
replay_until = last_sent
|
||||||
|
async with self._lock:
|
||||||
|
metadata = self._load_metadata(session_id)
|
||||||
|
if metadata is not None:
|
||||||
|
replay_until = int(metadata.get("last_sequence", last_sent))
|
||||||
|
|
||||||
|
runtime = self._active
|
||||||
|
if runtime is not None and runtime.session_id == session_id:
|
||||||
|
queue = asyncio.Queue()
|
||||||
|
runtime.subscribers.add(queue)
|
||||||
|
|
||||||
|
if replay_until > last_sent:
|
||||||
|
replay_events = self._read_transcript(session_id=session_id, since=last_sent, until=replay_until)
|
||||||
|
for event in replay_events:
|
||||||
|
if await self._emit_sse(request=request, response=response, event=event) is False:
|
||||||
|
return response
|
||||||
|
last_sent = event["seq"]
|
||||||
|
|
||||||
|
if queue is None:
|
||||||
|
return response
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if self._is_request_disconnected(request):
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = await asyncio.wait_for(queue.get(), timeout=self._keepalive_interval)
|
||||||
|
except TimeoutError:
|
||||||
|
if await self._emit_keepalive(request=request, response=response) is False:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
if event is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if event["seq"] <= last_sent:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if await self._emit_sse(request=request, response=response, event=event) is False:
|
||||||
|
break
|
||||||
|
last_sent = event["seq"]
|
||||||
|
finally:
|
||||||
|
if queue is not None:
|
||||||
|
async with self._lock:
|
||||||
|
if self._active is not None and self._active.session_id == session_id:
|
||||||
|
self._active.subscribers.discard(queue)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await response.write_eof()
|
||||||
|
except ConnectionResetError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def _run_session(self, session_id: str, command: str) -> None:
|
||||||
|
return_code = -1
|
||||||
|
final_status = "completed"
|
||||||
|
proc: Process | None = None
|
||||||
|
read_task: asyncio.Task[None] | None = None
|
||||||
|
master_fd: int | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
LOG.info("Cli command from client. '%s'", command)
|
||||||
|
args = ["yt-dlp", *shlex.split(command, posix=os.name != "nt")]
|
||||||
|
env_vars = self._build_env()
|
||||||
|
|
||||||
|
pty_handles = self._open_pty()
|
||||||
|
if pty_handles is None:
|
||||||
|
stdin_arg = asyncio.subprocess.DEVNULL
|
||||||
|
stdout_arg = asyncio.subprocess.PIPE
|
||||||
|
stderr_arg = asyncio.subprocess.STDOUT
|
||||||
|
use_pty = False
|
||||||
|
slave_fd = None
|
||||||
|
else:
|
||||||
|
master_fd, slave_fd = pty_handles
|
||||||
|
stdin_arg = asyncio.subprocess.DEVNULL
|
||||||
|
stdout_arg = slave_fd
|
||||||
|
stderr_arg = slave_fd
|
||||||
|
use_pty = True
|
||||||
|
|
||||||
|
creationflags = 0
|
||||||
|
if os.name == "nt":
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
creationflags = subprocess.CREATE_NO_WINDOW
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*args,
|
||||||
|
cwd=self.config.download_path,
|
||||||
|
stdin=stdin_arg,
|
||||||
|
stdout=stdout_arg,
|
||||||
|
stderr=stderr_arg,
|
||||||
|
env=env_vars,
|
||||||
|
creationflags=creationflags,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
metadata = self._load_metadata(session_id)
|
||||||
|
if metadata is not None:
|
||||||
|
metadata["status"] = "running"
|
||||||
|
self._write_json(self._metadata_path(session_id), metadata)
|
||||||
|
|
||||||
|
if self._active is not None and self._active.session_id == session_id:
|
||||||
|
self._active.process = proc
|
||||||
|
|
||||||
|
if use_pty is True and slave_fd is not None:
|
||||||
|
try:
|
||||||
|
os.close(slave_fd)
|
||||||
|
except Exception as exc:
|
||||||
|
LOG.error("Error closing PTY. '%s'.", str(exc))
|
||||||
|
|
||||||
|
read_task = asyncio.create_task(
|
||||||
|
self._read_process_output(session_id=session_id, proc=proc, use_pty=use_pty, master_fd=master_fd),
|
||||||
|
name=f"terminal_reader_{session_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return_code = await proc.wait()
|
||||||
|
await read_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
final_status = "interrupted"
|
||||||
|
except Exception as exc:
|
||||||
|
final_status = "failed"
|
||||||
|
LOG.error("CLI execute exception was thrown.")
|
||||||
|
LOG.exception(exc)
|
||||||
|
await self._append_event(session_id, "output", {"type": "stderr", "line": str(exc)})
|
||||||
|
finally:
|
||||||
|
final_status = await self._resolve_final_status(session_id=session_id, status=final_status)
|
||||||
|
|
||||||
|
if final_status == "interrupted" and proc is not None and getattr(proc, "returncode", None) is None:
|
||||||
|
self._signal_process(proc)
|
||||||
|
try:
|
||||||
|
return_code = await asyncio.wait_for(proc.wait(), timeout=self._shutdown_timeout)
|
||||||
|
except TimeoutError:
|
||||||
|
self._signal_process(proc, force=True)
|
||||||
|
try:
|
||||||
|
return_code = await asyncio.wait_for(proc.wait(), timeout=self._shutdown_timeout)
|
||||||
|
except TimeoutError:
|
||||||
|
LOG.warning("Terminal session '%s' process did not exit cleanly.", session_id)
|
||||||
|
|
||||||
|
if proc is not None:
|
||||||
|
proc_returncode = getattr(proc, "returncode", None)
|
||||||
|
if proc_returncode is not None:
|
||||||
|
return_code = int(proc_returncode)
|
||||||
|
|
||||||
|
await self._append_event(session_id, "close", {"exitcode": return_code})
|
||||||
|
await self._finalize_session(session_id=session_id, status=final_status, exit_code=return_code)
|
||||||
|
|
||||||
|
if read_task is not None and not read_task.done():
|
||||||
|
read_task.cancel()
|
||||||
|
|
||||||
|
if master_fd is not None:
|
||||||
|
try:
|
||||||
|
os.close(master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _read_process_output(self, session_id: str, proc: Process, use_pty: bool, master_fd: int | None) -> None:
|
||||||
|
if use_pty is False:
|
||||||
|
assert proc.stdout is not None
|
||||||
|
async for raw_line in proc.stdout:
|
||||||
|
line = raw_line.rstrip(b"\n")
|
||||||
|
await self._append_event(
|
||||||
|
session_id,
|
||||||
|
"output",
|
||||||
|
{"type": "stdout", "line": line.decode("utf-8", errors="replace")},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
assert master_fd is not None
|
||||||
|
loop: AbstractEventLoop = asyncio.get_running_loop()
|
||||||
|
buffer = b""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
chunk = await loop.run_in_executor(None, lambda: os.read(master_fd, 1024))
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno == errno.EIO:
|
||||||
|
break
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not chunk:
|
||||||
|
if buffer:
|
||||||
|
await self._append_event(
|
||||||
|
session_id,
|
||||||
|
"output",
|
||||||
|
{"type": "stdout", "line": buffer.decode("utf-8", errors="replace")},
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
buffer += chunk
|
||||||
|
*lines, buffer = buffer.split(b"\n")
|
||||||
|
for line in lines:
|
||||||
|
await self._append_event(
|
||||||
|
session_id,
|
||||||
|
"output",
|
||||||
|
{"type": "stdout", "line": line.decode("utf-8", errors="replace")},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _append_event(self, session_id: str, event: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
async with self._lock:
|
||||||
|
metadata = self._load_metadata(session_id)
|
||||||
|
if metadata is None:
|
||||||
|
msg = f"Unknown terminal session '{session_id}'."
|
||||||
|
raise FileNotFoundError(msg)
|
||||||
|
|
||||||
|
next_sequence = int(metadata.get("last_sequence", 0)) + 1
|
||||||
|
metadata["last_sequence"] = next_sequence
|
||||||
|
self._write_json(self._metadata_path(session_id), metadata)
|
||||||
|
|
||||||
|
record = {"seq": next_sequence, "event": event, "data": data}
|
||||||
|
with self._transcript_path(session_id).open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(record) + "\n")
|
||||||
|
|
||||||
|
if self._active is not None and self._active.session_id == session_id:
|
||||||
|
for subscriber in list(self._active.subscribers):
|
||||||
|
subscriber.put_nowait(record)
|
||||||
|
|
||||||
|
return record
|
||||||
|
|
||||||
|
async def _finalize_session(self, session_id: str, status: str, exit_code: int) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
metadata = self._load_metadata(session_id)
|
||||||
|
if metadata is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
metadata["status"] = status
|
||||||
|
metadata["exit_code"] = exit_code
|
||||||
|
metadata["finished_at"] = now
|
||||||
|
metadata["expires_at"] = now + self._drain_ttl
|
||||||
|
self._write_json(self._metadata_path(session_id), metadata)
|
||||||
|
|
||||||
|
if self._load_active_marker() == session_id:
|
||||||
|
self._clear_active_marker()
|
||||||
|
|
||||||
|
runtime = self._active
|
||||||
|
if runtime is not None and runtime.session_id == session_id:
|
||||||
|
subscribers = list(runtime.subscribers)
|
||||||
|
self._active = None
|
||||||
|
for subscriber in subscribers:
|
||||||
|
subscriber.put_nowait(None)
|
||||||
|
|
||||||
|
def _get_active_session_locked(self) -> dict[str, Any] | None:
|
||||||
|
session_id = self._load_active_marker()
|
||||||
|
if session_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
metadata = self._load_metadata(session_id)
|
||||||
|
if metadata is None:
|
||||||
|
self._clear_active_marker()
|
||||||
|
return None
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
def _recover_orphaned_active_session(self, now: float) -> None:
|
||||||
|
session_id = self._load_active_marker()
|
||||||
|
if session_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
metadata = self._load_metadata(session_id)
|
||||||
|
if metadata is None:
|
||||||
|
self._clear_active_marker()
|
||||||
|
return
|
||||||
|
|
||||||
|
metadata["status"] = "interrupted"
|
||||||
|
metadata["finished_at"] = now
|
||||||
|
metadata["expires_at"] = now + self._drain_ttl
|
||||||
|
metadata["exit_code"] = -1 if metadata.get("exit_code") is None else metadata["exit_code"]
|
||||||
|
self._write_json(self._metadata_path(session_id), metadata)
|
||||||
|
self._clear_active_marker()
|
||||||
|
|
||||||
|
def _cleanup_expired_sessions(self, now: float) -> None:
|
||||||
|
self._ensure_root()
|
||||||
|
active_session_id = self._load_active_marker()
|
||||||
|
|
||||||
|
for path in self.root_path.iterdir():
|
||||||
|
if path.name == ACTIVE_FILE_NAME or path.is_dir() is False:
|
||||||
|
continue
|
||||||
|
|
||||||
|
metadata = self._load_metadata(path.name)
|
||||||
|
if metadata is None:
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
expires_at = metadata.get("expires_at")
|
||||||
|
if expires_at is None or float(expires_at) > now:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if active_session_id == path.name:
|
||||||
|
self._clear_active_marker()
|
||||||
|
active_session_id = None
|
||||||
|
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
|
||||||
|
def _parse_since(self, request: Request) -> int:
|
||||||
|
values: list[int] = []
|
||||||
|
candidates = [request.query.get("since"), request.headers.get("Last-Event-ID")]
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate in (None, ""):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
value = int(candidate)
|
||||||
|
except ValueError as exc:
|
||||||
|
msg = "Resume cursor must be an integer."
|
||||||
|
raise ValueError(msg) from exc
|
||||||
|
|
||||||
|
if value < 0:
|
||||||
|
msg = "Resume cursor must be zero or greater."
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
values.append(value)
|
||||||
|
|
||||||
|
return max(values, default=0)
|
||||||
|
|
||||||
|
async def _force_interrupt_session(self, session_id: str) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
metadata = self._load_metadata(session_id)
|
||||||
|
if metadata is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
metadata["status"] = "interrupted"
|
||||||
|
metadata["finished_at"] = now
|
||||||
|
metadata["expires_at"] = now + self._drain_ttl
|
||||||
|
metadata["exit_code"] = -1 if metadata.get("exit_code") is None else metadata["exit_code"]
|
||||||
|
self._write_json(self._metadata_path(session_id), metadata)
|
||||||
|
|
||||||
|
if self._load_active_marker() == session_id:
|
||||||
|
self._clear_active_marker()
|
||||||
|
|
||||||
|
runtime = self._active
|
||||||
|
if runtime is not None and runtime.session_id == session_id:
|
||||||
|
subscribers = list(runtime.subscribers)
|
||||||
|
self._active = None
|
||||||
|
for subscriber in subscribers:
|
||||||
|
subscriber.put_nowait(None)
|
||||||
|
|
||||||
|
async def _resolve_final_status(self, session_id: str, status: str) -> str:
|
||||||
|
async with self._lock:
|
||||||
|
runtime = self._active
|
||||||
|
if runtime is not None and runtime.session_id == session_id and runtime.interrupted:
|
||||||
|
return "interrupted"
|
||||||
|
|
||||||
|
metadata = self._load_metadata(session_id)
|
||||||
|
if metadata is not None and metadata.get("status") == "interrupted":
|
||||||
|
return "interrupted"
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
def _signal_process(self, process: Process | None, *, force: bool = False) -> None:
|
||||||
|
if process is None or getattr(process, "returncode", None) is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
action = process.kill if force else process.terminate
|
||||||
|
try:
|
||||||
|
action()
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _emit_sse(self, request: Request, response: web.StreamResponse, event: dict[str, Any]) -> bool:
|
||||||
|
if self._is_request_disconnected(request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
payload = f"id: {event['seq']}\nevent: {event['event']}\ndata: {json.dumps(event['data'])}\n\n"
|
||||||
|
try:
|
||||||
|
await response.write(payload.encode("utf-8"))
|
||||||
|
except ConnectionResetError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _emit_keepalive(self, request: Request, response: web.StreamResponse) -> bool:
|
||||||
|
if self._is_request_disconnected(request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
await response.write(b": keepalive\n\n")
|
||||||
|
except ConnectionResetError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _read_transcript(self, session_id: str, since: int, until: int | None = None) -> list[dict[str, Any]]:
|
||||||
|
transcript_path = self._transcript_path(session_id)
|
||||||
|
if transcript_path.exists() is False:
|
||||||
|
return []
|
||||||
|
|
||||||
|
events: list[dict[str, Any]] = []
|
||||||
|
with transcript_path.open("r", encoding="utf-8") as handle:
|
||||||
|
for raw_line in handle:
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
event = json.loads(line)
|
||||||
|
sequence = int(event["seq"])
|
||||||
|
if sequence <= since:
|
||||||
|
continue
|
||||||
|
if until is not None and sequence > until:
|
||||||
|
break
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
def _build_env(self) -> dict[str, str]:
|
||||||
|
env_vars = os.environ.copy()
|
||||||
|
env_vars.update(
|
||||||
|
{
|
||||||
|
"PWD": self.config.download_path,
|
||||||
|
"FORCE_COLOR": "1",
|
||||||
|
"PYTHONUNBUFFERED": "1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if os.name != "nt":
|
||||||
|
env_vars.update(
|
||||||
|
{
|
||||||
|
"TERM": "xterm-256color",
|
||||||
|
"LANG": "en_US.UTF-8",
|
||||||
|
"LC_ALL": "en_US.UTF-8",
|
||||||
|
"SHELL": "/bin/bash",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return env_vars
|
||||||
|
|
||||||
|
def _open_pty(self) -> tuple[int, int] | None:
|
||||||
|
try:
|
||||||
|
import pty
|
||||||
|
|
||||||
|
return pty.openpty()
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _is_request_disconnected(self, request: Request) -> bool:
|
||||||
|
return request.transport is None or request.transport.is_closing()
|
||||||
|
|
||||||
|
def _ensure_root(self) -> None:
|
||||||
|
self.root_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _session_dir(self, session_id: str) -> Path:
|
||||||
|
return self.root_path / session_id
|
||||||
|
|
||||||
|
def _metadata_path(self, session_id: str) -> Path:
|
||||||
|
return self._session_dir(session_id) / METADATA_FILE_NAME
|
||||||
|
|
||||||
|
def _transcript_path(self, session_id: str) -> Path:
|
||||||
|
return self._session_dir(session_id) / TRANSCRIPT_FILE_NAME
|
||||||
|
|
||||||
|
def _active_marker_path(self) -> Path:
|
||||||
|
return self.root_path / ACTIVE_FILE_NAME
|
||||||
|
|
||||||
|
def _set_active_marker(self, session_id: str) -> None:
|
||||||
|
self._write_json(self._active_marker_path(), {"session_id": session_id})
|
||||||
|
|
||||||
|
def _clear_active_marker(self) -> None:
|
||||||
|
self._active_marker_path().unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _load_active_marker(self) -> str | None:
|
||||||
|
data = self._read_json(self._active_marker_path())
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
session_id = data.get("session_id")
|
||||||
|
return session_id if isinstance(session_id, str) and session_id else None
|
||||||
|
|
||||||
|
def _load_metadata(self, session_id: str) -> dict[str, Any] | None:
|
||||||
|
data = self._read_json(self._metadata_path(session_id))
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
def _read_json(self, path: Path) -> dict[str, Any] | None:
|
||||||
|
if path.exists() is False:
|
||||||
|
return None
|
||||||
|
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
|
||||||
|
def _write_json(self, path: Path, payload: dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temp_path = path.with_suffix(f"{path.suffix}.tmp")
|
||||||
|
with temp_path.open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle)
|
||||||
|
temp_path.replace(path)
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import _thread
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
@ -217,7 +218,7 @@ class Download:
|
||||||
if "posix" == os.name:
|
if "posix" == os.name:
|
||||||
os.kill(os.getpid(), signal.SIGINT)
|
os.kill(os.getpid(), signal.SIGINT)
|
||||||
else:
|
else:
|
||||||
signal.raise_signal(signal.SIGINT)
|
_thread.interrupt_main()
|
||||||
|
|
||||||
threading.Thread(target=trigger_live_cancel, name=f"cancel-watch-{self.id}", daemon=True).start()
|
threading.Thread(target=trigger_live_cancel, name=f"cancel-watch-{self.id}", daemon=True).start()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ from app.library.httpx_client import close_shared_clients
|
||||||
from app.library.Scheduler import Scheduler
|
from app.library.Scheduler import Scheduler
|
||||||
from app.library.Services import Services
|
from app.library.Services import Services
|
||||||
from app.library.sqlite_store import SqliteStore
|
from app.library.sqlite_store import SqliteStore
|
||||||
|
from app.library.TerminalSessionManager import TerminalSessionManager
|
||||||
from app.library.UpdateChecker import UpdateChecker
|
from app.library.UpdateChecker import UpdateChecker
|
||||||
|
|
||||||
LOG = logging.getLogger("app")
|
LOG = logging.getLogger("app")
|
||||||
|
|
@ -118,6 +119,7 @@ class Main:
|
||||||
BackgroundWorker.get_instance().attach(self._app)
|
BackgroundWorker.get_instance().attach(self._app)
|
||||||
Scheduler.get_instance().attach(self._app)
|
Scheduler.get_instance().attach(self._app)
|
||||||
Cache.get_instance().attach(self._app)
|
Cache.get_instance().attach(self._app)
|
||||||
|
TerminalSessionManager.get_instance().attach(self._app)
|
||||||
|
|
||||||
self._socket.attach(self._app)
|
self._socket.attach(self._app)
|
||||||
self._http.attach(self._app)
|
self._http.attach(self._app)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import errno
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import shlex
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp.web import Request, Response
|
from aiohttp.web import Request, Response
|
||||||
|
|
@ -18,14 +14,10 @@ from app.library.downloads import DownloadQueue
|
||||||
from app.library.encoder import Encoder
|
from app.library.encoder import Encoder
|
||||||
from app.library.Events import EventBus, Events
|
from app.library.Events import EventBus, Events
|
||||||
from app.library.router import route
|
from app.library.router import route
|
||||||
|
from app.library.TerminalSessionManager import TerminalSessionConflictError, TerminalSessionManager
|
||||||
from app.library.UpdateChecker import UpdateChecker
|
from app.library.UpdateChecker import UpdateChecker
|
||||||
from app.library.Utils import list_folders
|
from app.library.Utils import list_folders
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from asyncio import Task
|
|
||||||
from asyncio.events import AbstractEventLoop
|
|
||||||
from asyncio.subprocess import Process
|
|
||||||
|
|
||||||
LOG: logging.Logger = logging.getLogger(__name__)
|
LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -231,14 +223,7 @@ async def check_updates(config: Config, encoder: Encoder, update_checker: Update
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@route("POST", "api/system/terminal", "system.terminal")
|
async def _validate_terminal_command_request(request: Request) -> str | Response:
|
||||||
async def stream_terminal(request: Request, config: Config, encoder: Encoder) -> Response | web.StreamResponse:
|
|
||||||
if not config.console_enabled:
|
|
||||||
return web.json_response(
|
|
||||||
{"error": "Console feature is disabled."},
|
|
||||||
status=web.HTTPForbidden.status_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not request.can_read_body:
|
if not request.can_read_body:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "Request body is required."},
|
{"error": "Request body is required."},
|
||||||
|
|
@ -259,136 +244,122 @@ async def stream_terminal(request: Request, config: Config, encoder: Encoder) ->
|
||||||
status=web.HTTPBadRequest.status_code,
|
status=web.HTTPBadRequest.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = web.StreamResponse(
|
return raw_command
|
||||||
status=web.HTTPOk.status_code,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "text/event-stream",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await response.prepare(request)
|
|
||||||
|
|
||||||
async def emit_event(event: str, data: dict) -> None:
|
|
||||||
if request.transport is None or request.transport.is_closing():
|
|
||||||
return
|
|
||||||
payload: str = f"event: {event}\ndata: {encoder.encode(data)}\n\n"
|
|
||||||
await response.write(payload.encode("utf-8"))
|
|
||||||
|
|
||||||
returncode: int = -1
|
@route("POST", "api/system/terminal", "system.terminal")
|
||||||
|
async def create_terminal_session(
|
||||||
|
request: Request, config: Config, encoder: Encoder, terminal_manager: TerminalSessionManager
|
||||||
|
) -> Response:
|
||||||
|
if not config.console_enabled:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Console feature is disabled."},
|
||||||
|
status=web.HTTPForbidden.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_command = await _validate_terminal_command_request(request)
|
||||||
|
if isinstance(raw_command, Response):
|
||||||
|
return raw_command
|
||||||
|
|
||||||
try:
|
try:
|
||||||
LOG.info("Cli command from client. '%s'", raw_command)
|
metadata = await terminal_manager.create_session(raw_command)
|
||||||
|
except TerminalSessionConflictError as exc:
|
||||||
args: list[str] = ["yt-dlp", *shlex.split(raw_command, posix=os.name != "nt")]
|
return web.json_response(
|
||||||
env_vars: dict[str, str] = os.environ.copy()
|
{"error": str(exc)},
|
||||||
env_vars.update(
|
status=web.HTTPConflict.status_code,
|
||||||
{
|
|
||||||
"PWD": config.download_path,
|
|
||||||
"FORCE_COLOR": "1",
|
|
||||||
"PYTHONUNBUFFERED": "1",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if "nt" != os.name:
|
return web.json_response(data=metadata, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||||
env_vars.update(
|
|
||||||
{
|
|
||||||
"TERM": "xterm-256color",
|
|
||||||
"LANG": "en_US.UTF-8",
|
|
||||||
"LC_ALL": "en_US.UTF-8",
|
|
||||||
"SHELL": "/bin/bash",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
import pty
|
|
||||||
|
|
||||||
master_fd, slave_fd = pty.openpty()
|
@route("GET", "api/system/terminal/active", "system.terminal.active")
|
||||||
stdin_arg = asyncio.subprocess.DEVNULL
|
async def get_active_terminal_session(
|
||||||
stdout_arg = stderr_arg = slave_fd
|
config: Config, encoder: Encoder, terminal_manager: TerminalSessionManager
|
||||||
use_pty = True
|
) -> Response:
|
||||||
except ImportError:
|
if not config.console_enabled:
|
||||||
use_pty = False
|
return web.json_response(
|
||||||
master_fd = slave_fd = None
|
{"error": "Console feature is disabled."},
|
||||||
stdin_arg = asyncio.subprocess.DEVNULL
|
status=web.HTTPForbidden.status_code,
|
||||||
stdout_arg = asyncio.subprocess.PIPE
|
|
||||||
stderr_arg = asyncio.subprocess.STDOUT
|
|
||||||
|
|
||||||
creationflags = 0
|
|
||||||
if os.name == "nt":
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
creationflags = subprocess.CREATE_NO_WINDOW
|
|
||||||
|
|
||||||
proc: Process = await asyncio.create_subprocess_exec(
|
|
||||||
*args,
|
|
||||||
cwd=config.download_path,
|
|
||||||
stdin=stdin_arg,
|
|
||||||
stdout=stdout_arg,
|
|
||||||
stderr=stderr_arg,
|
|
||||||
env=env_vars,
|
|
||||||
creationflags=creationflags,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if use_pty:
|
metadata = await terminal_manager.get_active_session()
|
||||||
assert slave_fd is not None
|
return web.json_response(data=metadata, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||||
try:
|
|
||||||
os.close(slave_fd)
|
|
||||||
except Exception as e:
|
|
||||||
LOG.error("Error closing PTY. '%s'.", str(e))
|
|
||||||
|
|
||||||
async def reader() -> None:
|
|
||||||
if use_pty is False:
|
|
||||||
assert proc.stdout is not None
|
|
||||||
async for raw_line in proc.stdout:
|
|
||||||
line = raw_line.rstrip(b"\n")
|
|
||||||
await emit_event("output", {"type": "stdout", "line": line.decode("utf-8", errors="replace")})
|
|
||||||
return
|
|
||||||
|
|
||||||
assert master_fd is not None
|
@route("GET", "api/system/terminal/{session_id}", "system.terminal.session")
|
||||||
read_fd = master_fd
|
async def get_terminal_session(
|
||||||
loop: AbstractEventLoop = asyncio.get_running_loop()
|
request: Request, config: Config, encoder: Encoder, terminal_manager: TerminalSessionManager
|
||||||
buffer: bytes = b""
|
) -> Response:
|
||||||
while True:
|
if not config.console_enabled:
|
||||||
try:
|
return web.json_response(
|
||||||
chunk: bytes = await loop.run_in_executor(None, lambda: os.read(read_fd, 1024))
|
{"error": "Console feature is disabled."},
|
||||||
except OSError as e:
|
status=web.HTTPForbidden.status_code,
|
||||||
if e.errno == errno.EIO:
|
)
|
||||||
break
|
|
||||||
raise
|
|
||||||
|
|
||||||
if not chunk:
|
session_id = request.match_info.get("session_id", "")
|
||||||
if buffer:
|
metadata = await terminal_manager.get_session(session_id)
|
||||||
await emit_event(
|
if metadata is None:
|
||||||
"output",
|
return web.json_response(
|
||||||
{"type": "stdout", "line": buffer.decode("utf-8", errors="replace")},
|
{"error": "Terminal session not found."},
|
||||||
)
|
status=web.HTTPNotFound.status_code,
|
||||||
break
|
)
|
||||||
|
|
||||||
buffer += chunk
|
return web.json_response(data=metadata, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||||
*lines, buffer = buffer.split(b"\n")
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
await emit_event(
|
|
||||||
"output",
|
|
||||||
{"type": "stdout", "line": line.decode("utf-8", errors="replace")},
|
|
||||||
)
|
|
||||||
if master_fd is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
os.close(master_fd)
|
|
||||||
except Exception as e:
|
|
||||||
LOG.error("Error closing PTY. '%s'.", str(e))
|
|
||||||
|
|
||||||
read_task: Task = asyncio.create_task(reader(), name="cli_reader")
|
@route("DELETE", "api/system/terminal/{session_id}", "system.terminal.cancel")
|
||||||
|
async def cancel_terminal_session(
|
||||||
|
request: Request, config: Config, encoder: Encoder, terminal_manager: TerminalSessionManager
|
||||||
|
) -> Response:
|
||||||
|
if not config.console_enabled:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Console feature is disabled."},
|
||||||
|
status=web.HTTPForbidden.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
returncode = await proc.wait()
|
session_id = request.match_info.get("session_id", "")
|
||||||
await read_task
|
try:
|
||||||
except Exception as e:
|
await terminal_manager.cancel_session(session_id)
|
||||||
LOG.error("CLI execute exception was thrown.")
|
except FileNotFoundError:
|
||||||
LOG.exception(e)
|
return web.json_response(
|
||||||
await emit_event("output", {"type": "stderr", "line": str(e)})
|
{"error": "Terminal session not found."},
|
||||||
finally:
|
status=web.HTTPNotFound.status_code,
|
||||||
await emit_event("close", {"exitcode": returncode})
|
)
|
||||||
await response.write_eof()
|
except RuntimeError as exc:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": str(exc)},
|
||||||
|
status=web.HTTPConflict.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
return response
|
return web.json_response(
|
||||||
|
data={"message": "Terminal session cancellation requested.", "session_id": session_id},
|
||||||
|
status=web.HTTPOk.status_code,
|
||||||
|
dumps=encoder.encode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@route("GET", "api/system/terminal/{session_id}/stream", "system.terminal.stream")
|
||||||
|
async def stream_terminal_session(
|
||||||
|
request: Request, config: Config, terminal_manager: TerminalSessionManager
|
||||||
|
) -> Response | web.StreamResponse:
|
||||||
|
if not config.console_enabled:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Console feature is disabled."},
|
||||||
|
status=web.HTTPForbidden.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
session_id = request.match_info.get("session_id", "")
|
||||||
|
metadata = await terminal_manager.get_session(session_id)
|
||||||
|
if metadata is None:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Terminal session not found."},
|
||||||
|
status=web.HTTPNotFound.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await terminal_manager.stream_session(session_id=session_id, request=request)
|
||||||
|
except ValueError as exc:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": str(exc)},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -371,20 +371,32 @@ class TestDownloadFlow:
|
||||||
def process_ie_result(self, ie_result, download):
|
def process_ie_result(self, ie_result, download):
|
||||||
return ie_result, download
|
return ie_result, download
|
||||||
|
|
||||||
|
def download(self, url_list):
|
||||||
|
return 0
|
||||||
|
|
||||||
signal_mock = Mock()
|
signal_mock = Mock()
|
||||||
thread_instance = Mock(start=Mock())
|
thread_instances: list[Mock] = []
|
||||||
thread_mock = Mock(return_value=thread_instance)
|
|
||||||
|
def build_thread(*_args, **_kwargs):
|
||||||
|
thread = Mock(start=Mock())
|
||||||
|
thread_instances.append(thread)
|
||||||
|
return thread
|
||||||
|
|
||||||
|
thread_mock = Mock(side_effect=build_thread)
|
||||||
monkeypatch.setattr("app.library.downloads.core.YTDLP", FakeYTDLP)
|
monkeypatch.setattr("app.library.downloads.core.YTDLP", FakeYTDLP)
|
||||||
monkeypatch.setattr("app.library.downloads.core.signal.signal", signal_mock)
|
monkeypatch.setattr("app.library.downloads.core.signal.signal", signal_mock)
|
||||||
monkeypatch.setattr("app.library.downloads.core.threading.Thread", thread_mock)
|
monkeypatch.setattr("app.library.downloads.core.threading.Thread", thread_mock)
|
||||||
|
|
||||||
download._download()
|
download._download()
|
||||||
|
|
||||||
thread_mock.assert_called_once()
|
|
||||||
thread_instance.start.assert_called_once()
|
|
||||||
signal_mock.assert_any_call(signal.SIGINT, signal.default_int_handler)
|
signal_mock.assert_any_call(signal.SIGINT, signal.default_int_handler)
|
||||||
|
|
||||||
target = thread_mock.call_args.kwargs["target"]
|
live_cancel_thread = next(
|
||||||
|
call for call in thread_mock.call_args_list if call.kwargs.get("name", "").startswith("cancel-watch-")
|
||||||
|
)
|
||||||
|
live_cancel_thread_index = thread_mock.call_args_list.index(live_cancel_thread)
|
||||||
|
thread_instances[live_cancel_thread_index].start.assert_called_once()
|
||||||
|
target = live_cancel_thread.kwargs["target"]
|
||||||
ydl = created_ydl[0]
|
ydl = created_ydl[0]
|
||||||
|
|
||||||
if "posix" == os.name:
|
if "posix" == os.name:
|
||||||
|
|
@ -395,9 +407,9 @@ class TestDownloadFlow:
|
||||||
target()
|
target()
|
||||||
mock_kill.assert_called_once_with(12345, signal.SIGINT)
|
mock_kill.assert_called_once_with(12345, signal.SIGINT)
|
||||||
else:
|
else:
|
||||||
with patch("app.library.downloads.core.signal.raise_signal") as mock_raise_signal:
|
with patch("app.library.downloads.core._thread.interrupt_main") as mock_interrupt_main:
|
||||||
target()
|
target()
|
||||||
mock_raise_signal.assert_called_once_with(signal.SIGINT)
|
mock_interrupt_main.assert_called_once_with()
|
||||||
|
|
||||||
assert ydl._interrupted is True
|
assert ydl._interrupted is True
|
||||||
ydl.to_screen.assert_called_once_with("[info] Interrupt received, exiting cleanly...")
|
ydl.to_screen.assert_called_once_with("[info] Interrupt received, exiting cleanly...")
|
||||||
|
|
|
||||||
449
app/tests/test_terminal_session_manager.py
Normal file
449
app/tests/test_terminal_session_manager.py
Normal file
|
|
@ -0,0 +1,449 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.library.Services import Services
|
||||||
|
from app.library.TerminalSessionManager import TerminalSessionManager
|
||||||
|
from app.library.config import Config
|
||||||
|
from app.library.encoder import Encoder
|
||||||
|
from app.routes.api.system import (
|
||||||
|
cancel_terminal_session,
|
||||||
|
create_terminal_session,
|
||||||
|
get_active_terminal_session,
|
||||||
|
get_terminal_session,
|
||||||
|
stream_terminal_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeTransport:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._closing = False
|
||||||
|
|
||||||
|
def is_closing(self) -> bool:
|
||||||
|
return self._closing
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRequest:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
payload: dict | None = None,
|
||||||
|
match_info: dict[str, str] | None = None,
|
||||||
|
query: dict[str, str] | None = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
can_read_body: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self._payload = payload
|
||||||
|
self.match_info = match_info or {}
|
||||||
|
self.query = query or {}
|
||||||
|
self.headers = headers or {}
|
||||||
|
self.can_read_body = can_read_body
|
||||||
|
self.transport = _FakeTransport()
|
||||||
|
|
||||||
|
async def json(self) -> dict | None:
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeStreamResponse:
|
||||||
|
def __init__(self, *, status: int, headers: dict[str, str]) -> None:
|
||||||
|
self.status = status
|
||||||
|
self.headers = headers
|
||||||
|
self.payload = bytearray()
|
||||||
|
self.prepared = False
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def prepare(self, _request: _FakeRequest) -> "_FakeStreamResponse":
|
||||||
|
self.prepared = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def write(self, data: bytes) -> None:
|
||||||
|
self.payload.extend(data)
|
||||||
|
|
||||||
|
async def write_eof(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeStdout:
|
||||||
|
def __init__(self, lines: list[bytes]) -> None:
|
||||||
|
self._lines = lines
|
||||||
|
|
||||||
|
def __aiter__(self) -> "_FakeStdout":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __anext__(self) -> bytes:
|
||||||
|
if not self._lines:
|
||||||
|
raise StopAsyncIteration
|
||||||
|
return self._lines.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
class _BlockingProc:
|
||||||
|
def __init__(self, done_event: asyncio.Event) -> None:
|
||||||
|
self.stdout = _FakeStdout([])
|
||||||
|
self._done_event = done_event
|
||||||
|
self.returncode: int | None = None
|
||||||
|
|
||||||
|
async def wait(self) -> int:
|
||||||
|
await self._done_event.wait()
|
||||||
|
self.returncode = 0
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class _CompletedProc:
|
||||||
|
def __init__(self, lines: list[bytes], exit_code: int = 0) -> None:
|
||||||
|
self.stdout = _FakeStdout(lines)
|
||||||
|
self._exit_code = exit_code
|
||||||
|
self.returncode: int | None = None
|
||||||
|
|
||||||
|
async def wait(self) -> int:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
self.returncode = self._exit_code
|
||||||
|
return self._exit_code
|
||||||
|
|
||||||
|
|
||||||
|
class _TerminableProc:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.stdout = _FakeStdout([])
|
||||||
|
self.returncode: int | None = None
|
||||||
|
self.terminate_calls = 0
|
||||||
|
self.kill_calls = 0
|
||||||
|
self.wait_started = asyncio.Event()
|
||||||
|
self._done_event = asyncio.Event()
|
||||||
|
|
||||||
|
def terminate(self) -> None:
|
||||||
|
self.terminate_calls += 1
|
||||||
|
if self.returncode is None:
|
||||||
|
self.returncode = -15
|
||||||
|
self._done_event.set()
|
||||||
|
|
||||||
|
def kill(self) -> None:
|
||||||
|
self.kill_calls += 1
|
||||||
|
if self.returncode is None:
|
||||||
|
self.returncode = -9
|
||||||
|
self._done_event.set()
|
||||||
|
|
||||||
|
async def wait(self) -> int:
|
||||||
|
self.wait_started.set()
|
||||||
|
await self._done_event.wait()
|
||||||
|
assert self.returncode is not None
|
||||||
|
return self.returncode
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def terminal_setup(tmp_path: Path) -> tuple[Config, TerminalSessionManager, Encoder]:
|
||||||
|
Services._reset_singleton()
|
||||||
|
Config._reset_singleton()
|
||||||
|
TerminalSessionManager._reset_singleton()
|
||||||
|
|
||||||
|
config = Config.get_instance()
|
||||||
|
config.console_enabled = True
|
||||||
|
config.config_path = str(tmp_path / "config")
|
||||||
|
config.download_path = str(tmp_path / "downloads")
|
||||||
|
Path(config.config_path).mkdir(parents=True, exist_ok=True)
|
||||||
|
Path(config.download_path).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
manager = TerminalSessionManager.get_instance()
|
||||||
|
encoder = Encoder()
|
||||||
|
return config, manager, encoder
|
||||||
|
|
||||||
|
|
||||||
|
class TestTerminalSessionRoutes:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_returns_session_metadata_and_active_conflict(
|
||||||
|
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config, manager, encoder = terminal_setup
|
||||||
|
await manager.initialize()
|
||||||
|
|
||||||
|
done_event = asyncio.Event()
|
||||||
|
|
||||||
|
async def fake_create_subprocess_exec(*_args, **_kwargs):
|
||||||
|
return _BlockingProc(done_event)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.library.TerminalSessionManager.asyncio.create_subprocess_exec", fake_create_subprocess_exec
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(manager, "_open_pty", lambda: None)
|
||||||
|
|
||||||
|
request = _FakeRequest(payload={"command": "--help"}, can_read_body=True)
|
||||||
|
response = await create_terminal_session(request, config, encoder, manager)
|
||||||
|
payload = json.loads(response.body.decode("utf-8"))
|
||||||
|
|
||||||
|
assert 200 == response.status
|
||||||
|
assert payload["session_id"]
|
||||||
|
assert "starting" == payload["status"]
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
conflict = await create_terminal_session(request, config, encoder, manager)
|
||||||
|
assert 409 == conflict.status
|
||||||
|
assert b"already active" in conflict.body.lower()
|
||||||
|
|
||||||
|
active = await get_active_terminal_session(config, encoder, manager)
|
||||||
|
active_payload = json.loads(active.body.decode("utf-8"))
|
||||||
|
assert payload["session_id"] == active_payload["session_id"]
|
||||||
|
|
||||||
|
assert manager._active is not None
|
||||||
|
task = manager._active.task
|
||||||
|
done_event.set()
|
||||||
|
await task
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_endpoint_replays_persisted_events_and_resume_cursor(
|
||||||
|
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config, manager, encoder = terminal_setup
|
||||||
|
await manager.initialize()
|
||||||
|
|
||||||
|
async def fake_create_subprocess_exec(*_args, **_kwargs):
|
||||||
|
return _CompletedProc([b"first\n", b"second\n"])
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.library.TerminalSessionManager.asyncio.create_subprocess_exec", fake_create_subprocess_exec
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(manager, "_open_pty", lambda: None)
|
||||||
|
monkeypatch.setattr("app.library.TerminalSessionManager.web.StreamResponse", _FakeStreamResponse)
|
||||||
|
|
||||||
|
start_request = _FakeRequest(payload={"command": "--version"}, can_read_body=True)
|
||||||
|
start_response = await create_terminal_session(start_request, config, encoder, manager)
|
||||||
|
session_id = json.loads(start_response.body.decode("utf-8"))["session_id"]
|
||||||
|
|
||||||
|
assert manager._active is not None
|
||||||
|
task = manager._active.task
|
||||||
|
await task
|
||||||
|
|
||||||
|
status_response = await get_terminal_session(
|
||||||
|
_FakeRequest(match_info={"session_id": session_id}), config, encoder, manager
|
||||||
|
)
|
||||||
|
status_payload = json.loads(status_response.body.decode("utf-8"))
|
||||||
|
assert "completed" == status_payload["status"]
|
||||||
|
assert 3 == status_payload["last_sequence"]
|
||||||
|
assert 0 == status_payload["exit_code"]
|
||||||
|
|
||||||
|
stream_request = _FakeRequest(match_info={"session_id": session_id})
|
||||||
|
stream_response = await stream_terminal_session(stream_request, config, manager)
|
||||||
|
stream_payload = stream_response.payload.decode("utf-8")
|
||||||
|
|
||||||
|
assert "id: 1" in stream_payload
|
||||||
|
assert "id: 2" in stream_payload
|
||||||
|
assert "id: 3" in stream_payload
|
||||||
|
assert 'data: {"type": "stdout", "line": "first"}' in stream_payload
|
||||||
|
assert 'data: {"exitcode": 0}' in stream_payload
|
||||||
|
|
||||||
|
resumed_request = _FakeRequest(match_info={"session_id": session_id}, query={"since": "1"})
|
||||||
|
resumed_response = await stream_terminal_session(resumed_request, config, manager)
|
||||||
|
resumed_payload = resumed_response.payload.decode("utf-8")
|
||||||
|
|
||||||
|
assert "id: 1" not in resumed_payload
|
||||||
|
assert "id: 2" in resumed_payload
|
||||||
|
assert "id: 3" in resumed_payload
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_completed_session_expires_after_drain_window(
|
||||||
|
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config, manager, encoder = terminal_setup
|
||||||
|
manager._drain_ttl = 0.05
|
||||||
|
await manager.initialize()
|
||||||
|
|
||||||
|
async def fake_create_subprocess_exec(*_args, **_kwargs):
|
||||||
|
return _CompletedProc([b"done\n"])
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.library.TerminalSessionManager.asyncio.create_subprocess_exec", fake_create_subprocess_exec
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(manager, "_open_pty", lambda: None)
|
||||||
|
|
||||||
|
start_request = _FakeRequest(payload={"command": "--help"}, can_read_body=True)
|
||||||
|
start_response = await create_terminal_session(start_request, config, encoder, manager)
|
||||||
|
session_id = json.loads(start_response.body.decode("utf-8"))["session_id"]
|
||||||
|
|
||||||
|
assert manager._active is not None
|
||||||
|
task = manager._active.task
|
||||||
|
await task
|
||||||
|
|
||||||
|
before_expiry = await manager.get_session(session_id)
|
||||||
|
assert before_expiry is not None
|
||||||
|
|
||||||
|
await asyncio.sleep(0.06)
|
||||||
|
|
||||||
|
expired = await manager.get_session(session_id)
|
||||||
|
assert expired is None
|
||||||
|
assert not (manager.root_path / session_id).exists()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_shutdown_interrupts_active_session_and_clears_active_marker(
|
||||||
|
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config, manager, encoder = terminal_setup
|
||||||
|
manager._shutdown_timeout = 0.05
|
||||||
|
await manager.initialize()
|
||||||
|
|
||||||
|
proc = _TerminableProc()
|
||||||
|
|
||||||
|
async def fake_create_subprocess_exec(*_args, **_kwargs):
|
||||||
|
return proc
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.library.TerminalSessionManager.asyncio.create_subprocess_exec", fake_create_subprocess_exec
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(manager, "_open_pty", lambda: None)
|
||||||
|
|
||||||
|
start_request = _FakeRequest(payload={"command": "--help"}, can_read_body=True)
|
||||||
|
start_response = await create_terminal_session(start_request, config, encoder, manager)
|
||||||
|
session_id = json.loads(start_response.body.decode("utf-8"))["session_id"]
|
||||||
|
|
||||||
|
await proc.wait_started.wait()
|
||||||
|
await manager.on_shutdown(cast(Any, None))
|
||||||
|
|
||||||
|
metadata = await manager.get_session(session_id)
|
||||||
|
transcript = manager._read_transcript(session_id=session_id, since=0)
|
||||||
|
|
||||||
|
assert metadata is not None
|
||||||
|
assert "interrupted" == metadata["status"]
|
||||||
|
assert -15 == metadata["exit_code"]
|
||||||
|
assert metadata["finished_at"] is not None
|
||||||
|
assert metadata["expires_at"] is not None
|
||||||
|
assert 1 == proc.terminate_calls
|
||||||
|
assert 0 == proc.kill_calls
|
||||||
|
assert manager._active is None
|
||||||
|
assert manager._load_active_marker() is None
|
||||||
|
assert "close" == transcript[-1]["event"]
|
||||||
|
assert -15 == transcript[-1]["data"]["exitcode"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_endpoint_emits_keepalive_for_silent_active_session(
|
||||||
|
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config, manager, encoder = terminal_setup
|
||||||
|
manager._keepalive_interval = 0.01
|
||||||
|
await manager.initialize()
|
||||||
|
|
||||||
|
done_event = asyncio.Event()
|
||||||
|
|
||||||
|
async def fake_create_subprocess_exec(*_args, **_kwargs):
|
||||||
|
return _BlockingProc(done_event)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.library.TerminalSessionManager.asyncio.create_subprocess_exec", fake_create_subprocess_exec
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(manager, "_open_pty", lambda: None)
|
||||||
|
monkeypatch.setattr("app.library.TerminalSessionManager.web.StreamResponse", _FakeStreamResponse)
|
||||||
|
|
||||||
|
start_request = _FakeRequest(payload={"command": "--version"}, can_read_body=True)
|
||||||
|
start_response = await create_terminal_session(start_request, config, encoder, manager)
|
||||||
|
session_id = json.loads(start_response.body.decode("utf-8"))["session_id"]
|
||||||
|
assert manager._active is not None
|
||||||
|
session_task = manager._active.task
|
||||||
|
|
||||||
|
stream_request = _FakeRequest(match_info={"session_id": session_id})
|
||||||
|
stream_task = asyncio.create_task(_stream_session(stream_request, config, manager))
|
||||||
|
|
||||||
|
await asyncio.sleep(0.03)
|
||||||
|
done_event.set()
|
||||||
|
await session_task
|
||||||
|
stream_response = await stream_task
|
||||||
|
stream_payload = stream_response.payload.decode("utf-8")
|
||||||
|
|
||||||
|
assert ": keepalive" in stream_payload
|
||||||
|
assert "id: 1" in stream_payload
|
||||||
|
assert 'data: {"exitcode": 0}' in stream_payload
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancel_endpoint_interrupts_active_session(
|
||||||
|
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config, manager, encoder = terminal_setup
|
||||||
|
await manager.initialize()
|
||||||
|
|
||||||
|
proc = _TerminableProc()
|
||||||
|
|
||||||
|
async def fake_create_subprocess_exec(*_args, **_kwargs):
|
||||||
|
return proc
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.library.TerminalSessionManager.asyncio.create_subprocess_exec", fake_create_subprocess_exec
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(manager, "_open_pty", lambda: None)
|
||||||
|
|
||||||
|
start_request = _FakeRequest(payload={"command": "--help"}, can_read_body=True)
|
||||||
|
start_response = await create_terminal_session(start_request, config, encoder, manager)
|
||||||
|
session_id = json.loads(start_response.body.decode("utf-8"))["session_id"]
|
||||||
|
|
||||||
|
await proc.wait_started.wait()
|
||||||
|
|
||||||
|
cancel_response = await cancel_terminal_session(
|
||||||
|
_FakeRequest(match_info={"session_id": session_id}), config, encoder, manager
|
||||||
|
)
|
||||||
|
cancel_payload = json.loads(cancel_response.body.decode("utf-8"))
|
||||||
|
|
||||||
|
assert 200 == cancel_response.status
|
||||||
|
assert session_id == cancel_payload["session_id"]
|
||||||
|
|
||||||
|
assert manager._active is not None
|
||||||
|
active_task = manager._active.task
|
||||||
|
await active_task
|
||||||
|
|
||||||
|
metadata = await manager.get_session(session_id)
|
||||||
|
transcript = manager._read_transcript(session_id=session_id, since=0)
|
||||||
|
|
||||||
|
assert metadata is not None
|
||||||
|
assert "interrupted" == metadata["status"]
|
||||||
|
assert -15 == metadata["exit_code"]
|
||||||
|
assert 1 == proc.terminate_calls
|
||||||
|
assert 0 == proc.kill_calls
|
||||||
|
assert transcript[-1]["event"] == "close"
|
||||||
|
assert -15 == transcript[-1]["data"]["exitcode"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancel_endpoint_returns_conflict_for_inactive_session(
|
||||||
|
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config, manager, encoder = terminal_setup
|
||||||
|
await manager.initialize()
|
||||||
|
|
||||||
|
async def fake_create_subprocess_exec(*_args, **_kwargs):
|
||||||
|
return _CompletedProc([b"done\n"])
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.library.TerminalSessionManager.asyncio.create_subprocess_exec", fake_create_subprocess_exec
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(manager, "_open_pty", lambda: None)
|
||||||
|
|
||||||
|
start_request = _FakeRequest(payload={"command": "--version"}, can_read_body=True)
|
||||||
|
start_response = await create_terminal_session(start_request, config, encoder, manager)
|
||||||
|
session_id = json.loads(start_response.body.decode("utf-8"))["session_id"]
|
||||||
|
|
||||||
|
assert manager._active is not None
|
||||||
|
await manager._active.task
|
||||||
|
|
||||||
|
cancel_response = await cancel_terminal_session(
|
||||||
|
_FakeRequest(match_info={"session_id": session_id}), config, encoder, manager
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 409 == cancel_response.status
|
||||||
|
assert b"not active" in cancel_response.body.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancel_endpoint_returns_not_found_for_unknown_session(
|
||||||
|
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder]
|
||||||
|
) -> None:
|
||||||
|
config, manager, encoder = terminal_setup
|
||||||
|
await manager.initialize()
|
||||||
|
|
||||||
|
cancel_response = await cancel_terminal_session(
|
||||||
|
_FakeRequest(match_info={"session_id": "missing"}), config, encoder, manager
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 404 == cancel_response.status
|
||||||
|
assert b"not found" in cancel_response.body.lower()
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream_session(
|
||||||
|
request: _FakeRequest, config: Config, manager: TerminalSessionManager
|
||||||
|
) -> _FakeStreamResponse:
|
||||||
|
response = await stream_terminal_session(request, config, manager)
|
||||||
|
assert isinstance(response, _FakeStreamResponse)
|
||||||
|
return response
|
||||||
|
|
@ -194,6 +194,7 @@ testpaths = ["app/tests", "app/features"]
|
||||||
addopts = "-v --tb=short"
|
addopts = "-v --tb=short"
|
||||||
filterwarnings = [
|
filterwarnings = [
|
||||||
"ignore:Parsing dates involving a day of month without a year:DeprecationWarning",
|
"ignore:Parsing dates involving a day of month without a year:DeprecationWarning",
|
||||||
|
"ignore:This process \\(pid\\=.*\\) is multi-threaded, use of fork\\(\\) may lead to deadlocks in the child\\.:DeprecationWarning",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,12 @@
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
:icon="icon"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:ui="{ root: 'w-full', base: 'w-full bg-default/90' }"
|
:ui="{ root: 'w-full', base: 'w-full bg-default/90', leadingIcon: iconClass }"
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
@blur="hideList"
|
@blur="hideList"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
|
|
@ -63,6 +64,8 @@ const props = withDefaults(
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
icon?: string;
|
||||||
|
iconClass?: string;
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
openOnFocus?: boolean;
|
openOnFocus?: boolean;
|
||||||
allowShortFlags?: boolean;
|
allowShortFlags?: boolean;
|
||||||
|
|
@ -71,13 +74,16 @@ const props = withDefaults(
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
disabled: false,
|
disabled: false,
|
||||||
id: '',
|
id: '',
|
||||||
|
icon: undefined,
|
||||||
|
iconClass: '',
|
||||||
multiple: true,
|
multiple: true,
|
||||||
openOnFocus: false,
|
openOnFocus: false,
|
||||||
allowShortFlags: false,
|
allowShortFlags: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { placeholder, disabled, id, multiple, openOnFocus, allowShortFlags } = toRefs(props);
|
const { placeholder, disabled, id, icon, iconClass, multiple, openOnFocus, allowShortFlags } =
|
||||||
|
toRefs(props);
|
||||||
|
|
||||||
const model = defineModel<string>();
|
const model = defineModel<string>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,13 @@
|
||||||
@mouseup="updateCaret"
|
@mouseup="updateCaret"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<UIcon
|
||||||
|
v-if="icon"
|
||||||
|
:name="icon"
|
||||||
|
class="pointer-events-none absolute top-3 right-3 z-10 size-4 text-toned"
|
||||||
|
:class="iconClass"
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showList && filteredOptions.length"
|
v-if="showList && filteredOptions.length"
|
||||||
class="absolute inset-x-0 top-full z-20 mt-1 overflow-hidden rounded-md border border-default bg-default shadow-lg"
|
class="absolute inset-x-0 top-full z-20 mt-1 overflow-hidden rounded-md border border-default bg-default shadow-lg"
|
||||||
|
|
@ -55,13 +62,25 @@
|
||||||
import { ref, computed, watch, nextTick } from 'vue';
|
import { ref, computed, watch, nextTick } from 'vue';
|
||||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
options: AutoCompleteOptions;
|
defineProps<{
|
||||||
placeholder?: string;
|
options: AutoCompleteOptions;
|
||||||
disabled?: boolean;
|
placeholder?: string;
|
||||||
id?: string;
|
disabled?: boolean;
|
||||||
rows?: number;
|
id?: string;
|
||||||
}>();
|
rows?: number;
|
||||||
|
icon?: string;
|
||||||
|
iconClass?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
placeholder: '',
|
||||||
|
disabled: false,
|
||||||
|
id: '',
|
||||||
|
rows: 4,
|
||||||
|
icon: undefined,
|
||||||
|
iconClass: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const model = defineModel<string>();
|
const model = defineModel<string>();
|
||||||
const localValue = ref(model.value || '');
|
const localValue = ref(model.value || '');
|
||||||
|
|
|
||||||
661
ui/app/composables/useConsoleSession.ts
Normal file
661
ui/app/composables/useConsoleSession.ts
Normal file
|
|
@ -0,0 +1,661 @@
|
||||||
|
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
||||||
|
import type { EventSourceMessage } from '@microsoft/fetch-event-source';
|
||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { parse_api_error, parse_api_response, request, uri } from '~/utils';
|
||||||
|
|
||||||
|
type ConsoleSessionStatus =
|
||||||
|
| 'idle'
|
||||||
|
| 'starting'
|
||||||
|
| 'running'
|
||||||
|
| 'reconnecting'
|
||||||
|
| 'finished'
|
||||||
|
| 'interrupted'
|
||||||
|
| 'expired'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
type ConsoleSessionState = {
|
||||||
|
sessionId: string | null;
|
||||||
|
command: string;
|
||||||
|
status: ConsoleSessionStatus;
|
||||||
|
lastEventId: string | null;
|
||||||
|
exitCode: number | null;
|
||||||
|
transcript: Array<string>;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConsoleSessionResponse = {
|
||||||
|
session_id?: string | null;
|
||||||
|
sessionId?: string | null;
|
||||||
|
command?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
last_event_id?: string | number | null;
|
||||||
|
lastEventId?: string | number | null;
|
||||||
|
exit_code?: number | null;
|
||||||
|
exitCode?: number | null;
|
||||||
|
expired?: boolean | null;
|
||||||
|
not_found?: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StartConsoleSessionInput = {
|
||||||
|
command: string;
|
||||||
|
displayCommand?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CancelConsoleSessionResult = {
|
||||||
|
status: 'cancelled' | 'missing' | 'error';
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'console_session_state';
|
||||||
|
const MAX_TRANSCRIPT_CHUNKS = 1500;
|
||||||
|
const MAX_TRANSCRIPT_CHARS = 120000;
|
||||||
|
const RECONNECT_DELAY_MS = 1500;
|
||||||
|
|
||||||
|
const DEFAULT_STATE: ConsoleSessionState = {
|
||||||
|
sessionId: null,
|
||||||
|
command: '',
|
||||||
|
status: 'idle',
|
||||||
|
lastEventId: null,
|
||||||
|
exitCode: null,
|
||||||
|
transcript: [],
|
||||||
|
error: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
class ConsoleSessionExpiredError extends Error {}
|
||||||
|
|
||||||
|
const trimTranscript = (transcript: Array<string>): Array<string> => {
|
||||||
|
const next = [...transcript];
|
||||||
|
|
||||||
|
while (next.length > MAX_TRANSCRIPT_CHUNKS) {
|
||||||
|
next.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalChars = next.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||||
|
while (next.length > 1 && totalChars > MAX_TRANSCRIPT_CHARS) {
|
||||||
|
totalChars -= next[0]?.length || 0;
|
||||||
|
next.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActiveSessionStatus = (status: ConsoleSessionStatus | null | undefined): boolean => {
|
||||||
|
return ['starting', 'running', 'reconnecting'].includes(status || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeStatus = (
|
||||||
|
status: string | null | undefined,
|
||||||
|
fallback: ConsoleSessionStatus = 'idle',
|
||||||
|
): ConsoleSessionStatus => {
|
||||||
|
switch ((status || '').trim().toLowerCase()) {
|
||||||
|
case 'starting':
|
||||||
|
case 'pending':
|
||||||
|
case 'queued':
|
||||||
|
return 'starting';
|
||||||
|
|
||||||
|
case 'active':
|
||||||
|
case 'open':
|
||||||
|
case 'running':
|
||||||
|
case 'streaming':
|
||||||
|
return 'running';
|
||||||
|
|
||||||
|
case 'reconnecting':
|
||||||
|
return 'reconnecting';
|
||||||
|
|
||||||
|
case 'complete':
|
||||||
|
case 'completed':
|
||||||
|
case 'closed':
|
||||||
|
case 'done':
|
||||||
|
case 'exited':
|
||||||
|
case 'finished':
|
||||||
|
return 'finished';
|
||||||
|
|
||||||
|
case 'interrupted':
|
||||||
|
return 'interrupted';
|
||||||
|
|
||||||
|
case 'failed':
|
||||||
|
case 'error':
|
||||||
|
return 'error';
|
||||||
|
|
||||||
|
case 'expired':
|
||||||
|
case 'missing':
|
||||||
|
case 'not_found':
|
||||||
|
return 'expired';
|
||||||
|
|
||||||
|
case 'idle':
|
||||||
|
return 'idle';
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePersistedState = (
|
||||||
|
input: Partial<ConsoleSessionState> | null | undefined,
|
||||||
|
{ allowActiveWithoutSessionId = false }: { allowActiveWithoutSessionId?: boolean } = {},
|
||||||
|
): ConsoleSessionState => {
|
||||||
|
const sessionId =
|
||||||
|
typeof input?.sessionId === 'string' && input.sessionId.trim() ? input.sessionId : null;
|
||||||
|
const status = normalizeStatus(input?.status, DEFAULT_STATE.status);
|
||||||
|
const hasDetachedActiveStatus = isActiveSessionStatus(status) && sessionId === null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
command: typeof input?.command === 'string' ? input.command : DEFAULT_STATE.command,
|
||||||
|
status: hasDetachedActiveStatus && !allowActiveWithoutSessionId ? 'idle' : status,
|
||||||
|
lastEventId:
|
||||||
|
input?.lastEventId === null || input?.lastEventId === undefined
|
||||||
|
? null
|
||||||
|
: String(input.lastEventId),
|
||||||
|
exitCode: typeof input?.exitCode === 'number' ? input.exitCode : null,
|
||||||
|
transcript: trimTranscript(
|
||||||
|
Array.isArray(input?.transcript)
|
||||||
|
? input.transcript.filter((chunk): chunk is string => typeof chunk === 'string')
|
||||||
|
: DEFAULT_STATE.transcript,
|
||||||
|
),
|
||||||
|
error: typeof input?.error === 'string' ? input.error : DEFAULT_STATE.error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionState = useStorage<ConsoleSessionState>(STORAGE_KEY, { ...DEFAULT_STATE });
|
||||||
|
const streamController = ref<AbortController | null>(null);
|
||||||
|
const reconnectTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const connectionNonce = ref(0);
|
||||||
|
|
||||||
|
sessionState.value = normalizePersistedState(sessionState.value);
|
||||||
|
|
||||||
|
const updateState = (patch: Partial<ConsoleSessionState>): void => {
|
||||||
|
sessionState.value = normalizePersistedState(
|
||||||
|
{
|
||||||
|
...sessionState.value,
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
{ allowActiveWithoutSessionId: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendTranscript = (chunk: string): void => {
|
||||||
|
if (!chunk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateState({ transcript: trimTranscript([...sessionState.value.transcript, chunk]) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopReconnectTimer = (): void => {
|
||||||
|
if (!reconnectTimer.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(reconnectTimer.value);
|
||||||
|
reconnectTimer.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopStream = (): void => {
|
||||||
|
stopReconnectTimer();
|
||||||
|
streamController.value?.abort();
|
||||||
|
streamController.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearTranscript = (): void => {
|
||||||
|
const isActive = isActiveSessionStatus(sessionState.value.status);
|
||||||
|
|
||||||
|
updateState({
|
||||||
|
transcript: [],
|
||||||
|
sessionId: isActive ? sessionState.value.sessionId : null,
|
||||||
|
status: isActive ? sessionState.value.status : 'idle',
|
||||||
|
lastEventId: isActive ? sessionState.value.lastEventId : null,
|
||||||
|
exitCode: isActive ? sessionState.value.exitCode : null,
|
||||||
|
error: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const markExpired = (): void => {
|
||||||
|
stopStream();
|
||||||
|
updateState({ status: 'expired', error: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishSession = (
|
||||||
|
status: ConsoleSessionStatus = 'finished',
|
||||||
|
exitCode: number | null = sessionState.value.exitCode,
|
||||||
|
): void => {
|
||||||
|
stopStream();
|
||||||
|
updateState({
|
||||||
|
status,
|
||||||
|
exitCode,
|
||||||
|
error: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleReconnect = (): void => {
|
||||||
|
if (!sessionState.value.sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopReconnectTimer();
|
||||||
|
updateState({ status: 'reconnecting', error: '' });
|
||||||
|
reconnectTimer.value = setTimeout(() => {
|
||||||
|
reconnectTimer.value = null;
|
||||||
|
if (!sessionState.value.sessionId || !isActiveSessionStatus(sessionState.value.status)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void connectStream();
|
||||||
|
}, RECONNECT_DELAY_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldSkipEvent = (eventId: string): boolean => {
|
||||||
|
const lastEventId = sessionState.value.lastEventId;
|
||||||
|
if (!lastEventId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventId === lastEventId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+$/.test(eventId) && /^\d+$/.test(lastEventId)) {
|
||||||
|
return Number(eventId) <= Number(lastEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readJson = async (response: Response): Promise<unknown> => {
|
||||||
|
try {
|
||||||
|
return await response.clone().json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseResponseError = async (response: Response): Promise<string> => {
|
||||||
|
const payload = await readJson(response);
|
||||||
|
if (payload) {
|
||||||
|
return await parse_api_error(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await response.text();
|
||||||
|
if (text) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return response.statusText || 'Request failed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.statusText || 'Request failed.';
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshSessionMetadata = async (): Promise<void> => {
|
||||||
|
if (!sessionState.value.sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await readSessionResponse(
|
||||||
|
`/api/system/terminal/${encodeURIComponent(sessionState.value.sessionId)}`,
|
||||||
|
);
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateState(normalizeResponse(payload));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ConsoleSessionExpiredError) {
|
||||||
|
markExpired();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeResponse = (payload: ConsoleSessionResponse): Partial<ConsoleSessionState> => {
|
||||||
|
const status =
|
||||||
|
payload.expired || payload.not_found
|
||||||
|
? 'expired'
|
||||||
|
: normalizeStatus(payload.status, sessionState.value.status);
|
||||||
|
|
||||||
|
const sessionId = payload.session_id ?? payload.sessionId;
|
||||||
|
const lastEventId = payload.last_event_id ?? payload.lastEventId;
|
||||||
|
const exitCode = payload.exit_code ?? payload.exitCode;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: sessionId === undefined ? sessionState.value.sessionId : sessionId,
|
||||||
|
command: payload.command ?? sessionState.value.command,
|
||||||
|
status,
|
||||||
|
lastEventId:
|
||||||
|
lastEventId === undefined || lastEventId === null
|
||||||
|
? sessionState.value.lastEventId
|
||||||
|
: String(lastEventId),
|
||||||
|
exitCode: exitCode === undefined ? sessionState.value.exitCode : exitCode,
|
||||||
|
error:
|
||||||
|
status === 'error'
|
||||||
|
? sessionState.value.error ||
|
||||||
|
(typeof exitCode === 'number' && exitCode !== 0
|
||||||
|
? `Command exited with code ${exitCode}.`
|
||||||
|
: '')
|
||||||
|
: '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const readSessionResponse = async (
|
||||||
|
path: string,
|
||||||
|
{ allowMissing = false }: { allowMissing?: boolean } = {},
|
||||||
|
): Promise<ConsoleSessionResponse | null> => {
|
||||||
|
const response = await request(path);
|
||||||
|
|
||||||
|
if ([404, 410].includes(response.status)) {
|
||||||
|
if (allowMissing) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ConsoleSessionExpiredError(await parseResponseError(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await parseResponseError(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await parse_api_response<ConsoleSessionResponse>(response.json());
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectStream = async (): Promise<void> => {
|
||||||
|
if (!sessionState.value.sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopStream();
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
let finalMetadataRefresh: Promise<void> | null = null;
|
||||||
|
const nonce = connectionNonce.value + 1;
|
||||||
|
connectionNonce.value = nonce;
|
||||||
|
streamController.value = controller;
|
||||||
|
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (sessionState.value.lastEventId) {
|
||||||
|
search.set('since', sessionState.value.lastEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = uri(
|
||||||
|
`/api/system/terminal/${encodeURIComponent(sessionState.value.sessionId)}/stream${search.size > 0 ? `?${search.toString()}` : ''}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'text/event-stream',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sessionState.value.lastEventId) {
|
||||||
|
headers['Last-Event-ID'] = sessionState.value.lastEventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchEventSource(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
signal: controller.signal,
|
||||||
|
openWhenHidden: true,
|
||||||
|
onopen: async (response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
updateState({ status: 'running', error: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([404, 410].includes(response.status)) {
|
||||||
|
throw new ConsoleSessionExpiredError(await parseResponseError(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(await parseResponseError(response));
|
||||||
|
},
|
||||||
|
onmessage: (event: EventSourceMessage) => {
|
||||||
|
if (event.id && shouldSkipEvent(event.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: Record<string, unknown> | null = null;
|
||||||
|
if (event.data) {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(event.data) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.id) {
|
||||||
|
updateState({ lastEventId: event.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.event === 'expired' || payload?.type === 'expired' || payload?.expired === true) {
|
||||||
|
markExpired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.event === 'status') {
|
||||||
|
updateState(normalizeResponse(payload as ConsoleSessionResponse));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.event === 'output') {
|
||||||
|
const line = typeof payload?.line === 'string' ? payload.line : event.data;
|
||||||
|
appendTranscript(`${line || ''}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.event === 'close') {
|
||||||
|
const nextExitCode =
|
||||||
|
typeof payload?.exitcode === 'number'
|
||||||
|
? payload.exitcode
|
||||||
|
: typeof payload?.exit_code === 'number'
|
||||||
|
? payload.exit_code
|
||||||
|
: sessionState.value.exitCode;
|
||||||
|
const nextStatus = normalizeStatus(
|
||||||
|
typeof payload?.status === 'string' ? payload.status : null,
|
||||||
|
'finished',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (payload?.expired === true || payload?.status === 'expired') {
|
||||||
|
markExpired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
finishSession(nextStatus === 'error' ? 'error' : nextStatus, nextExitCode);
|
||||||
|
finalMetadataRefresh = refreshSessionMetadata();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose: () => {
|
||||||
|
if (controller.signal.aborted || nonce !== connectionNonce.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionState.value.sessionId || !isActiveSessionStatus(sessionState.value.status)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleReconnect();
|
||||||
|
},
|
||||||
|
onerror: (error) => {
|
||||||
|
throw error instanceof Error ? error : new Error(String(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (finalMetadataRefresh) {
|
||||||
|
await finalMetadataRefresh;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted || nonce !== connectionNonce.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof ConsoleSessionExpiredError) {
|
||||||
|
markExpired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
|
||||||
|
scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
appendTranscript(`Error: ${message}\n`);
|
||||||
|
updateState({ status: 'error', error: message });
|
||||||
|
} finally {
|
||||||
|
if (streamController.value === controller) {
|
||||||
|
streamController.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreSession = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (sessionState.value.sessionId) {
|
||||||
|
const payload = await readSessionResponse(
|
||||||
|
`/api/system/terminal/${encodeURIComponent(sessionState.value.sessionId)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (payload) {
|
||||||
|
updateState(normalizeResponse(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
|
||||||
|
await connectStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
sessionState.value.sessionId ||
|
||||||
|
sessionState.value.command ||
|
||||||
|
sessionState.value.transcript.length > 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = await readSessionResponse('/api/system/terminal/active', { allowMissing: true });
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateState(normalizeResponse(active));
|
||||||
|
|
||||||
|
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
|
||||||
|
await connectStream();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ConsoleSessionExpiredError) {
|
||||||
|
markExpired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
|
||||||
|
await connectStream();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startSession = async ({
|
||||||
|
command,
|
||||||
|
displayCommand = command,
|
||||||
|
}: StartConsoleSessionInput): Promise<boolean> => {
|
||||||
|
stopStream();
|
||||||
|
updateState({
|
||||||
|
sessionId: null,
|
||||||
|
command: displayCommand,
|
||||||
|
status: 'starting',
|
||||||
|
lastEventId: null,
|
||||||
|
exitCode: null,
|
||||||
|
error: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request('/api/system/terminal', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ command }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await parseResponseError(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await parse_api_response<ConsoleSessionResponse>(response.json());
|
||||||
|
updateState({
|
||||||
|
...normalizeResponse(payload),
|
||||||
|
command: displayCommand,
|
||||||
|
status: normalizeStatus(payload.status, 'running'),
|
||||||
|
lastEventId: null,
|
||||||
|
exitCode: null,
|
||||||
|
transcript: trimTranscript([
|
||||||
|
...sessionState.value.transcript,
|
||||||
|
'user@YTPTube ~\n',
|
||||||
|
`$ yt-dlp ${displayCommand}\n`,
|
||||||
|
]),
|
||||||
|
error: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
|
||||||
|
await connectStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(sessionState.value.sessionId);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
appendTranscript(`Error: ${message}\n`);
|
||||||
|
updateState({ status: 'error', error: message });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelSession = async (): Promise<CancelConsoleSessionResult> => {
|
||||||
|
if (!sessionState.value.sessionId) {
|
||||||
|
return { status: 'missing', message: 'No active terminal session found.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request(
|
||||||
|
`/api/system/terminal/${encodeURIComponent(sessionState.value.sessionId)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
await refreshSessionMetadata();
|
||||||
|
return { status: 'missing', message: 'Terminal session not found.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await parseResponseError(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateState({ error: '' });
|
||||||
|
return { status: 'cancelled' };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
updateState({ error: message });
|
||||||
|
return { status: 'error', message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetState = (): void => {
|
||||||
|
stopStream();
|
||||||
|
sessionState.value = { ...DEFAULT_STATE };
|
||||||
|
};
|
||||||
|
|
||||||
|
const useConsoleSession = () => {
|
||||||
|
return {
|
||||||
|
state: sessionState,
|
||||||
|
bufferedTranscript: computed(() => sessionState.value.transcript.slice()),
|
||||||
|
isLoading: computed(() => isActiveSessionStatus(sessionState.value.status)),
|
||||||
|
clearTranscript,
|
||||||
|
restoreSession,
|
||||||
|
startSession,
|
||||||
|
cancelSession,
|
||||||
|
disconnect: stopStream,
|
||||||
|
__resetForTesting: resetState,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export { useConsoleSession };
|
||||||
File diff suppressed because it is too large
Load diff
505
ui/tests/composables/useConsoleSession.test.ts
Normal file
505
ui/tests/composables/useConsoleSession.test.ts
Normal file
|
|
@ -0,0 +1,505 @@
|
||||||
|
import { describe, it, expect, beforeAll, beforeEach, afterEach, mock, spyOn } from 'bun:test'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
type StorageEntry<T> = ReturnType<typeof ref<T>>
|
||||||
|
|
||||||
|
type MockEventSourceMessage = {
|
||||||
|
event: string
|
||||||
|
id?: string
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockFetchEventSourceOptions = {
|
||||||
|
method: string
|
||||||
|
headers: Record<string, string>
|
||||||
|
credentials: RequestCredentials
|
||||||
|
signal: AbortSignal
|
||||||
|
openWhenHidden: boolean
|
||||||
|
onopen: (response: Response) => Promise<void> | void
|
||||||
|
onmessage: (event: MockEventSourceMessage) => Promise<void> | void
|
||||||
|
onclose: () => Promise<void> | void
|
||||||
|
onerror: (error: unknown) => unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeConfig = {
|
||||||
|
app: {
|
||||||
|
baseURL: '/base-path',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
;(globalThis as typeof globalThis & { useRuntimeConfig?: () => typeof runtimeConfig }).useRuntimeConfig = () => runtimeConfig
|
||||||
|
|
||||||
|
mock.module('#imports', () => ({
|
||||||
|
useRuntimeConfig: () => runtimeConfig,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const storageMap = new Map<string, StorageEntry<unknown>>()
|
||||||
|
|
||||||
|
const cloneValue = <T>(value: T): T => {
|
||||||
|
return JSON.parse(JSON.stringify(value)) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStorageFn = mock(<T>(key: string, defaultValue: T) => {
|
||||||
|
if (!storageMap.has(key)) {
|
||||||
|
storageMap.set(key, ref(cloneValue(defaultValue)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return storageMap.get(key) as StorageEntry<T>
|
||||||
|
})
|
||||||
|
|
||||||
|
mock.module('@vueuse/core', () => ({
|
||||||
|
useStorage: useStorageFn,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const fetchEventSourceMock = mock(
|
||||||
|
async (_url: string, _options: MockFetchEventSourceOptions): Promise<void> => {},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock.module('@microsoft/fetch-event-source', () => ({
|
||||||
|
fetchEventSource: fetchEventSourceMock,
|
||||||
|
}))
|
||||||
|
|
||||||
|
type MockResponseInput = {
|
||||||
|
ok: boolean
|
||||||
|
status: number
|
||||||
|
jsonData: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const createMockResponse = ({ ok, status, jsonData }: MockResponseInput): Response => {
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||||
|
redirected: false,
|
||||||
|
statusText: ok ? 'OK' : 'Error',
|
||||||
|
type: 'basic',
|
||||||
|
url: '',
|
||||||
|
body: null,
|
||||||
|
bodyUsed: false,
|
||||||
|
clone() {
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
async json() {
|
||||||
|
return jsonData
|
||||||
|
},
|
||||||
|
text: async () => JSON.stringify(jsonData),
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(0),
|
||||||
|
blob: async () => new Blob(),
|
||||||
|
formData: async () => new FormData(),
|
||||||
|
} as Response
|
||||||
|
}
|
||||||
|
|
||||||
|
let utils: Awaited<typeof import('~/utils/index')>
|
||||||
|
let useConsoleSession: typeof import('~/composables/useConsoleSession').useConsoleSession
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
utils = await import('~/utils/index')
|
||||||
|
;({ useConsoleSession } = await import('~/composables/useConsoleSession'))
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storageMap.clear()
|
||||||
|
useStorageFn.mockClear()
|
||||||
|
fetchEventSourceMock.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
const session = useConsoleSession()
|
||||||
|
session.__resetForTesting()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useConsoleSession', () => {
|
||||||
|
it('starts a session, persists prompt transcript, and stores streamed output', async () => {
|
||||||
|
const requestSpy = spyOn(utils, 'request')
|
||||||
|
requestSpy.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: { session_id: 'sess-1', command: '--help', status: 'running' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
requestSpy.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: {
|
||||||
|
session_id: 'sess-1',
|
||||||
|
command: '--help',
|
||||||
|
status: 'completed',
|
||||||
|
last_event_id: '2',
|
||||||
|
exit_code: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
fetchEventSourceMock.mockImplementationOnce(async (_url, options) => {
|
||||||
|
await options.onopen(new Response('', { status: 200 }))
|
||||||
|
options.onmessage({
|
||||||
|
event: 'output',
|
||||||
|
id: '1',
|
||||||
|
data: JSON.stringify({ line: 'line one' }),
|
||||||
|
})
|
||||||
|
options.onmessage({
|
||||||
|
event: 'close',
|
||||||
|
id: '2',
|
||||||
|
data: JSON.stringify({ exitcode: 0 }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = useConsoleSession()
|
||||||
|
const started = await session.startSession({ command: '--help', displayCommand: '--help' })
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(started).toBe(true)
|
||||||
|
expect(session.state.value.sessionId).toBe('sess-1')
|
||||||
|
expect(session.state.value.command).toBe('--help')
|
||||||
|
expect(session.state.value.status).toBe('finished')
|
||||||
|
expect(session.state.value.lastEventId).toBe('2')
|
||||||
|
expect(session.state.value.exitCode).toBe(0)
|
||||||
|
expect(session.state.value.transcript).toEqual([
|
||||||
|
'user@YTPTube ~\n',
|
||||||
|
'$ yt-dlp --help\n',
|
||||||
|
'line one\n',
|
||||||
|
])
|
||||||
|
|
||||||
|
requestSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores a running session using both since and Last-Event-ID', async () => {
|
||||||
|
const session = useConsoleSession()
|
||||||
|
session.state.value = {
|
||||||
|
sessionId: 'sess-restore',
|
||||||
|
command: '--version',
|
||||||
|
status: 'running',
|
||||||
|
lastEventId: '42',
|
||||||
|
exitCode: null,
|
||||||
|
transcript: ['user@YTPTube ~\n', '$ yt-dlp --version\n'],
|
||||||
|
error: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestSpy = spyOn(utils, 'request')
|
||||||
|
requestSpy.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: { session_id: 'sess-restore', command: '--version', status: 'running', last_event_id: '42' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
fetchEventSourceMock.mockImplementationOnce(async (_url, options) => {
|
||||||
|
await options.onopen(new Response('', { status: 200 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await session.restoreSession()
|
||||||
|
|
||||||
|
expect(fetchEventSourceMock).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
const streamCall = fetchEventSourceMock.mock.calls[0]
|
||||||
|
expect(streamCall).toBeDefined()
|
||||||
|
if (!streamCall) {
|
||||||
|
throw new Error('Expected fetchEventSource to be called once.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [streamUrl, streamOptions] = streamCall
|
||||||
|
expect(streamUrl).toContain('/base-path/api/system/terminal/sess-restore/stream?since=42')
|
||||||
|
expect(streamOptions.headers['Last-Event-ID']).toBe('42')
|
||||||
|
expect(streamOptions.method).toBe('GET')
|
||||||
|
|
||||||
|
requestSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the session expired and stops retry setup on stream not found', async () => {
|
||||||
|
const requestSpy = spyOn(utils, 'request')
|
||||||
|
requestSpy.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: { session_id: 'sess-expired', command: '--help', status: 'running' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
fetchEventSourceMock.mockImplementationOnce(async (_url, options) => {
|
||||||
|
await options.onopen(
|
||||||
|
new Response(JSON.stringify({ error: 'Session expired' }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = useConsoleSession()
|
||||||
|
const started = await session.startSession({ command: '--help', displayCommand: '--help' })
|
||||||
|
|
||||||
|
expect(started).toBe(true)
|
||||||
|
expect(session.state.value.status).toBe('expired')
|
||||||
|
expect(session.state.value.command).toBe('--help')
|
||||||
|
expect(session.state.value.transcript).toEqual([
|
||||||
|
'user@YTPTube ~\n',
|
||||||
|
'$ yt-dlp --help\n',
|
||||||
|
])
|
||||||
|
expect(fetchEventSourceMock).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
requestSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores a persisted session even after a local stream error state', async () => {
|
||||||
|
const session = useConsoleSession()
|
||||||
|
session.state.value = {
|
||||||
|
sessionId: 'sess-reconnect',
|
||||||
|
command: '--version',
|
||||||
|
status: 'error',
|
||||||
|
lastEventId: '7',
|
||||||
|
exitCode: null,
|
||||||
|
transcript: ['user@YTPTube ~\n', '$ yt-dlp --version\n'],
|
||||||
|
error: 'Stream failed',
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestSpy = spyOn(utils, 'request')
|
||||||
|
requestSpy.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: { session_id: 'sess-reconnect', command: '--version', status: 'running', last_event_id: '7' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
fetchEventSourceMock.mockImplementationOnce(async (_url, options) => {
|
||||||
|
await options.onopen(new Response('', { status: 200 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await session.restoreSession()
|
||||||
|
|
||||||
|
expect(fetchEventSourceMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(session.state.value.status).toBe('running')
|
||||||
|
expect(session.state.value.error).toBe('')
|
||||||
|
|
||||||
|
requestSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates replayed events when reconnect resumes from the last event id', async () => {
|
||||||
|
const requestSpy = spyOn(utils, 'request')
|
||||||
|
requestSpy.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: { session_id: 'sess-dupe', command: '--help', status: 'running' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
requestSpy.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: {
|
||||||
|
session_id: 'sess-dupe',
|
||||||
|
command: '--help',
|
||||||
|
status: 'completed',
|
||||||
|
last_event_id: '9',
|
||||||
|
exit_code: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
fetchEventSourceMock.mockImplementationOnce(async (_url, options) => {
|
||||||
|
await options.onopen(new Response('', { status: 200 }))
|
||||||
|
options.onmessage({
|
||||||
|
event: 'output',
|
||||||
|
id: '8',
|
||||||
|
data: JSON.stringify({ line: 'first line' }),
|
||||||
|
})
|
||||||
|
options.onmessage({
|
||||||
|
event: 'output',
|
||||||
|
id: '8',
|
||||||
|
data: JSON.stringify({ line: 'first line' }),
|
||||||
|
})
|
||||||
|
options.onmessage({
|
||||||
|
event: 'close',
|
||||||
|
id: '9',
|
||||||
|
data: JSON.stringify({ exitcode: 0 }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = useConsoleSession()
|
||||||
|
await session.startSession({ command: '--help', displayCommand: '--help' })
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(session.state.value.transcript).toEqual([
|
||||||
|
'user@YTPTube ~\n',
|
||||||
|
'$ yt-dlp --help\n',
|
||||||
|
'first line\n',
|
||||||
|
])
|
||||||
|
|
||||||
|
requestSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes final metadata after close so interrupted sessions keep their backend status', async () => {
|
||||||
|
const requestSpy = spyOn(utils, 'request')
|
||||||
|
requestSpy.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: { session_id: 'sess-int', command: '--help', status: 'running' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
requestSpy.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: {
|
||||||
|
session_id: 'sess-int',
|
||||||
|
command: '--help',
|
||||||
|
status: 'interrupted',
|
||||||
|
last_event_id: '2',
|
||||||
|
exit_code: -15,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
fetchEventSourceMock.mockImplementationOnce(async (_url, options) => {
|
||||||
|
await options.onopen(new Response('', { status: 200 }))
|
||||||
|
options.onmessage({
|
||||||
|
event: 'output',
|
||||||
|
id: '1',
|
||||||
|
data: JSON.stringify({ line: 'partial output' }),
|
||||||
|
})
|
||||||
|
options.onmessage({
|
||||||
|
event: 'close',
|
||||||
|
id: '2',
|
||||||
|
data: JSON.stringify({ exitcode: -15 }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = useConsoleSession()
|
||||||
|
await session.startSession({ command: '--help', displayCommand: '--help' })
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(session.state.value.status).toBe('interrupted')
|
||||||
|
expect(session.state.value.exitCode).toBe(-15)
|
||||||
|
expect(session.state.value.error).toBe('')
|
||||||
|
|
||||||
|
requestSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requests cancellation for the active session without dropping local state early', async () => {
|
||||||
|
const session = useConsoleSession()
|
||||||
|
session.state.value = {
|
||||||
|
sessionId: 'sess-stop',
|
||||||
|
command: '--help',
|
||||||
|
status: 'running',
|
||||||
|
lastEventId: '3',
|
||||||
|
exitCode: null,
|
||||||
|
transcript: ['user@YTPTube ~\n', '$ yt-dlp --help\n', 'chunk\n'],
|
||||||
|
error: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestSpy = spyOn(utils, 'request')
|
||||||
|
requestSpy.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: { message: 'Terminal session cancellation requested.', session_id: 'sess-stop' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await session.cancelSession()
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'cancelled' })
|
||||||
|
expect(session.state.value.sessionId).toBe('sess-stop')
|
||||||
|
expect(session.state.value.status).toBe('running')
|
||||||
|
expect(session.state.value.error).toBe('')
|
||||||
|
|
||||||
|
const requestCall = requestSpy.mock.calls.at(-1)
|
||||||
|
expect(requestCall).toBeDefined()
|
||||||
|
if (!requestCall) {
|
||||||
|
throw new Error('Expected request to be called for session cancellation.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [path, options] = requestCall as [string, { method: string }]
|
||||||
|
expect(path).toBe('/api/system/terminal/sess-stop')
|
||||||
|
expect(options.method).toBe('DELETE')
|
||||||
|
|
||||||
|
requestSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disconnects the local stream without issuing a cancel request', async () => {
|
||||||
|
const session = useConsoleSession()
|
||||||
|
session.state.value = {
|
||||||
|
sessionId: 'sess-detach',
|
||||||
|
command: '--help',
|
||||||
|
status: 'running',
|
||||||
|
lastEventId: '3',
|
||||||
|
exitCode: null,
|
||||||
|
transcript: ['user@YTPTube ~\n', '$ yt-dlp --help\n'],
|
||||||
|
error: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchEventSourceMock.mockImplementationOnce(async (_url, options) => {
|
||||||
|
await options.onopen(new Response('', { status: 200 }))
|
||||||
|
return new Promise<void>(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
void session.restoreSession()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
const requestSpy = spyOn(utils, 'request')
|
||||||
|
requestSpy.mockClear()
|
||||||
|
session.disconnect()
|
||||||
|
|
||||||
|
expect(requestSpy).toHaveBeenCalledTimes(0)
|
||||||
|
expect(session.state.value.sessionId).toBe('sess-detach')
|
||||||
|
|
||||||
|
requestSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes metadata when cancel targets a missing session', async () => {
|
||||||
|
const session = useConsoleSession()
|
||||||
|
session.state.value = {
|
||||||
|
sessionId: 'sess-missing',
|
||||||
|
command: '--help',
|
||||||
|
status: 'running',
|
||||||
|
lastEventId: '3',
|
||||||
|
exitCode: null,
|
||||||
|
transcript: ['user@YTPTube ~\n', '$ yt-dlp --help\n'],
|
||||||
|
error: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestSpy = spyOn(utils, 'request')
|
||||||
|
requestSpy.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
jsonData: { error: 'Terminal session not found.' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await session.cancelSession()
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'missing', message: 'Terminal session not found.' })
|
||||||
|
expect(session.state.value.status).toBe('running')
|
||||||
|
|
||||||
|
requestSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears visible transcript without dropping the active session cursor', () => {
|
||||||
|
const session = useConsoleSession()
|
||||||
|
session.state.value = {
|
||||||
|
sessionId: 'sess-active',
|
||||||
|
command: '--help',
|
||||||
|
status: 'running',
|
||||||
|
lastEventId: '15',
|
||||||
|
exitCode: null,
|
||||||
|
transcript: ['user@YTPTube ~\n', '$ yt-dlp --help\n', 'chunk\n'],
|
||||||
|
error: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
session.clearTranscript()
|
||||||
|
|
||||||
|
expect(session.state.value.transcript).toEqual([])
|
||||||
|
expect(session.state.value.sessionId).toBe('sess-active')
|
||||||
|
expect(session.state.value.status).toBe('running')
|
||||||
|
expect(session.state.value.lastEventId).toBe('15')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue