shell-session-manager 2.2.0__tar.gz → 2.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/PKG-INFO +1 -1
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/pyproject.toml +1 -1
- shell_session_manager-2.2.1/src/shell_session_manager/shellctl/sanitize_pty.py +39 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/__init__.py +0 -2
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/artifacts.py +10 -2
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/cli.py +7 -15
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/config.py +12 -2
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/service.py +81 -12
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/tmux.py +7 -4
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/tests/test_shellctl_service.py +158 -16
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/LICENSE +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/README.md +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/__init__.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/py.typed +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/session.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/__init__.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/client/__init__.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/client/sdk.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/__main__.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/api.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/db.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/server/errors.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/__init__.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/constants.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/output.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/runtime.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/sanitize.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/shellctl/shared/schemas.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/tests/golden_shellctl_sanitize.json +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/tests/test_shell_session_autoclose.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/tests/test_shell_session_manager.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/tests/test_shellctl_client.py +0 -0
- {shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/tests/test_shellctl_shared.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shell-session-manager
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.1
|
|
4
4
|
Summary: Async subprocess session manager with incremental stdin/stdout/stderr support
|
|
5
5
|
Author-Email: =?utf-8?b?WWFubGkg55uQ57KS?= <yanli@mail.one>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "shell-session-manager"
|
|
9
|
-
version = "2.2.
|
|
9
|
+
version = "2.2.1"
|
|
10
10
|
description = "Async subprocess session manager with incremental stdin/stdout/stderr support"
|
|
11
11
|
authors = [
|
|
12
12
|
{ name = "Yanli 盐粒", email = "yanli@mail.one" },
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Minimal PTY sanitizer entrypoint used by tmux `pipe-pane`.
|
|
2
|
+
|
|
3
|
+
This module intentionally stays isolated from the FastAPI/SQLite server stack
|
|
4
|
+
so the tmux-side subprocess can touch its ready-file quickly and emit useful
|
|
5
|
+
stderr when startup fails before any PTY output is drained.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from shell_session_manager.shellctl.shared.sanitize import sanitize_pty_stream
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_args():
|
|
16
|
+
"""Parse the tiny CLI contract used by tmux `pipe-pane`."""
|
|
17
|
+
|
|
18
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
19
|
+
parser.add_argument("--ready-file", type=Path)
|
|
20
|
+
return parser.parse_args()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_sanitize_pty(ready_file: Path | None) -> None:
|
|
24
|
+
"""Touch the ready file, then sanitize stdin into stdout."""
|
|
25
|
+
|
|
26
|
+
if ready_file is not None:
|
|
27
|
+
ready_file.touch()
|
|
28
|
+
sanitize_pty_stream(sys.stdin.buffer, sys.stdout.buffer)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main() -> None:
|
|
32
|
+
"""Run the standalone PTY sanitizer module."""
|
|
33
|
+
|
|
34
|
+
args = parse_args()
|
|
35
|
+
run_sanitize_pty(args.ready_file)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
main()
|
|
@@ -11,7 +11,6 @@ from shell_session_manager.shellctl.server.cli import (
|
|
|
11
11
|
cli,
|
|
12
12
|
main,
|
|
13
13
|
runner_exit_command,
|
|
14
|
-
sanitize_pty_command,
|
|
15
14
|
serve_command,
|
|
16
15
|
)
|
|
17
16
|
from shell_session_manager.shellctl.server.config import ShellctlConfig
|
|
@@ -28,6 +27,5 @@ __all__ = [
|
|
|
28
27
|
"create_app",
|
|
29
28
|
"main",
|
|
30
29
|
"runner_exit_command",
|
|
31
|
-
"sanitize_pty_command",
|
|
32
30
|
"serve_command",
|
|
33
31
|
]
|
|
@@ -5,8 +5,9 @@ Normal job completion is coordinated through small marker files inside each
|
|
|
5
5
|
SQLite `exited(exit_code, ended_at)` state only after PTY output is fully
|
|
6
6
|
drained into `output.log`. The same artifact directory also stores the request's
|
|
7
7
|
environment overlay so the runner can merge arbitrary key/value pairs without
|
|
8
|
-
shell-escaping them into the generated script.
|
|
9
|
-
|
|
8
|
+
shell-escaping them into the generated script. Separate failure markers and a
|
|
9
|
+
dedicated `pipe-error.log` stderr capture keep startup diagnostics available
|
|
10
|
+
when the sanitizer never reaches its ready-file handshake.
|
|
10
11
|
"""
|
|
11
12
|
|
|
12
13
|
from __future__ import annotations
|
|
@@ -18,6 +19,7 @@ RUNNER_ENDED_AT_FILENAME = ".runner-ended-at"
|
|
|
18
19
|
JOB_ENV_FILENAME = ".job-env.json"
|
|
19
20
|
PIPE_DRAINED_FILENAME = ".pipe-drained"
|
|
20
21
|
PIPE_FAILED_FILENAME = ".pipe-failed"
|
|
22
|
+
PIPE_ERROR_LOG_FILENAME = "pipe-error.log"
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
def runner_exit_code_path(job_dir: Path) -> Path:
|
|
@@ -40,14 +42,20 @@ def pipe_failed_path(job_dir: Path) -> Path:
|
|
|
40
42
|
return job_dir / PIPE_FAILED_FILENAME
|
|
41
43
|
|
|
42
44
|
|
|
45
|
+
def pipe_error_log_path(job_dir: Path) -> Path:
|
|
46
|
+
return job_dir / PIPE_ERROR_LOG_FILENAME
|
|
47
|
+
|
|
48
|
+
|
|
43
49
|
__all__ = [
|
|
44
50
|
"JOB_ENV_FILENAME",
|
|
45
51
|
"PIPE_DRAINED_FILENAME",
|
|
52
|
+
"PIPE_ERROR_LOG_FILENAME",
|
|
46
53
|
"PIPE_FAILED_FILENAME",
|
|
47
54
|
"RUNNER_ENDED_AT_FILENAME",
|
|
48
55
|
"RUNNER_EXIT_CODE_FILENAME",
|
|
49
56
|
"job_env_path",
|
|
50
57
|
"pipe_drained_path",
|
|
58
|
+
"pipe_error_log_path",
|
|
51
59
|
"pipe_failed_path",
|
|
52
60
|
"runner_ended_at_path",
|
|
53
61
|
"runner_exit_code_path",
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
"""Typer CLI entrypoints for shellctl server package.
|
|
1
|
+
"""Typer CLI entrypoints for the shellctl server package.
|
|
2
|
+
|
|
3
|
+
The server CLI only exposes commands that need the FastAPI/SQLite runtime.
|
|
4
|
+
The PTY sanitizer now lives in `shell_session_manager.shellctl.sanitize_pty`
|
|
5
|
+
as a separate lightweight module so tmux pipes do not have a second, heavier
|
|
6
|
+
entry path to maintain.
|
|
7
|
+
"""
|
|
2
8
|
|
|
3
9
|
from __future__ import annotations
|
|
4
10
|
|
|
5
|
-
import sys
|
|
6
11
|
from pathlib import Path
|
|
7
12
|
|
|
8
13
|
import anyio
|
|
@@ -17,7 +22,6 @@ from shell_session_manager.shellctl.shared import (
|
|
|
17
22
|
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
|
18
23
|
DEFAULT_GC_INTERVAL_SECONDS,
|
|
19
24
|
default_state_dir,
|
|
20
|
-
sanitize_pty_stream,
|
|
21
25
|
)
|
|
22
26
|
|
|
23
27
|
cli = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False)
|
|
@@ -54,17 +58,6 @@ def serve_command(
|
|
|
54
58
|
uvicorn.run(create_app(config), host=host, port=port, log_level="info")
|
|
55
59
|
|
|
56
60
|
|
|
57
|
-
@cli.command("sanitize-pty")
|
|
58
|
-
def sanitize_pty_command(
|
|
59
|
-
ready_file: Path | None = typer.Option(None, "--ready-file"),
|
|
60
|
-
) -> None:
|
|
61
|
-
"""Read raw PTY bytes from stdin and write sanitized UTF-8 text to stdout."""
|
|
62
|
-
|
|
63
|
-
if ready_file is not None:
|
|
64
|
-
ready_file.touch()
|
|
65
|
-
sanitize_pty_stream(sys.stdin.buffer, sys.stdout.buffer)
|
|
66
|
-
|
|
67
|
-
|
|
68
61
|
@cli.command("runner-exit")
|
|
69
62
|
def runner_exit_command(
|
|
70
63
|
state_dir: Path = typer.Option(..., "--state-dir"),
|
|
@@ -107,6 +100,5 @@ __all__ = [
|
|
|
107
100
|
"cli",
|
|
108
101
|
"main",
|
|
109
102
|
"runner_exit_command",
|
|
110
|
-
"sanitize_pty_command",
|
|
111
103
|
"serve_command",
|
|
112
104
|
]
|
|
@@ -32,8 +32,10 @@ class ShellctlConfig:
|
|
|
32
32
|
"""Runtime configuration for the shellctl service and CLI.
|
|
33
33
|
|
|
34
34
|
`shellctl_command` deliberately defaults to `python -m ...server` so the
|
|
35
|
-
tmux-side
|
|
36
|
-
launched the API server.
|
|
35
|
+
tmux-side `runner-exit` callback stays pinned to the same interpreter
|
|
36
|
+
environment that launched the API server. `sanitize_pty_command` uses the
|
|
37
|
+
same interpreter but points at a lightweight module so the pipe ready-file
|
|
38
|
+
handshake does not pay FastAPI/SQLAlchemy import costs.
|
|
37
39
|
|
|
38
40
|
Bearer auth is opt-in: if the explicit `auth_token` and the fallback
|
|
39
41
|
`SHELLCTL_AUTH_TOKEN` environment variable are both missing or empty,
|
|
@@ -59,6 +61,7 @@ class ShellctlConfig:
|
|
|
59
61
|
default_terminate_grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS
|
|
60
62
|
poll_interval_seconds: float = 0.05
|
|
61
63
|
pipe_monitor_interval_seconds: float = 1.0
|
|
64
|
+
pipe_ready_timeout_seconds: float = 10.0
|
|
62
65
|
sqlite_busy_timeout_ms: int = 5000
|
|
63
66
|
shellctl_command: tuple[str, ...] = field(
|
|
64
67
|
default_factory=lambda: (
|
|
@@ -67,6 +70,13 @@ class ShellctlConfig:
|
|
|
67
70
|
"shell_session_manager.shellctl.server",
|
|
68
71
|
)
|
|
69
72
|
)
|
|
73
|
+
sanitize_pty_command: tuple[str, ...] = field(
|
|
74
|
+
default_factory=lambda: (
|
|
75
|
+
sys.executable,
|
|
76
|
+
"-m",
|
|
77
|
+
"shell_session_manager.shellctl.sanitize_pty",
|
|
78
|
+
)
|
|
79
|
+
)
|
|
70
80
|
|
|
71
81
|
def __post_init__(self) -> None:
|
|
72
82
|
if self.runtime_dir is None:
|
|
@@ -30,6 +30,7 @@ from shell_session_manager.shellctl.server.artifacts import (
|
|
|
30
30
|
RUNNER_EXIT_CODE_FILENAME,
|
|
31
31
|
job_env_path,
|
|
32
32
|
pipe_drained_path,
|
|
33
|
+
pipe_error_log_path,
|
|
33
34
|
pipe_failed_path,
|
|
34
35
|
runner_ended_at_path,
|
|
35
36
|
runner_exit_code_path,
|
|
@@ -559,10 +560,10 @@ class ShellctlService:
|
|
|
559
560
|
) -> None:
|
|
560
561
|
"""Persist a drained normal-exit fact into SQLite.
|
|
561
562
|
|
|
562
|
-
The usual caller is the tmux pipe finalizer after
|
|
563
|
-
|
|
564
|
-
also call this as a recovery path when `.pipe-drained` plus the
|
|
565
|
-
exit metadata files exist but SQLite is still non-terminal. The
|
|
563
|
+
The usual caller is the tmux pipe finalizer after the lightweight PTY
|
|
564
|
+
sanitizer reaches EOF and flushes `output.log`. `_materialize_status_view()`
|
|
565
|
+
may also call this as a recovery path when `.pipe-drained` plus the
|
|
566
|
+
normal exit metadata files exist but SQLite is still non-terminal. The
|
|
566
567
|
statement always records `exit_code` and `ended_at`, but it only changes
|
|
567
568
|
`status` to `exited` when the current row is still non-terminal.
|
|
568
569
|
"""
|
|
@@ -635,13 +636,17 @@ class ShellctlService:
|
|
|
635
636
|
async def _wait_for_output_pipe_ready(
|
|
636
637
|
self, *, job_id: str, ready_file: Path
|
|
637
638
|
) -> None:
|
|
638
|
-
"""Confirm the sanitize/output pipeline is live before opening start-gate.
|
|
639
|
+
"""Confirm the sanitize/output pipeline is live before opening start-gate.
|
|
639
640
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
641
|
+
Timeout failures include the ready-file path, current `#{pane_pipe}`
|
|
642
|
+
state, and a summary of `pipe-error.log` so operators can tell the
|
|
643
|
+
difference between slow startup, tmux pipe loss, and sanitizer crashes.
|
|
644
|
+
"""
|
|
645
|
+
|
|
646
|
+
started_at = anyio.current_time()
|
|
647
|
+
deadline = started_at + self.config.pipe_ready_timeout_seconds
|
|
644
648
|
while True:
|
|
649
|
+
waited_seconds = anyio.current_time() - started_at
|
|
645
650
|
if ready_file.exists():
|
|
646
651
|
pipe_state = await self._tmux.is_output_pipe_active(job_id=job_id)
|
|
647
652
|
if pipe_state is True:
|
|
@@ -650,23 +655,87 @@ class ShellctlService:
|
|
|
650
655
|
raise ShellctlServerError(
|
|
651
656
|
500,
|
|
652
657
|
"pipe_failed",
|
|
653
|
-
|
|
658
|
+
self._pipe_ready_failure_message(
|
|
659
|
+
job_id=job_id,
|
|
660
|
+
ready_file=ready_file,
|
|
661
|
+
waited_seconds=waited_seconds,
|
|
662
|
+
pipe_state=pipe_state,
|
|
663
|
+
cause="tmux pane disappeared after the ready-file handshake",
|
|
664
|
+
),
|
|
654
665
|
)
|
|
655
666
|
else:
|
|
656
667
|
if not await self._tmux.session_exists(job_session_name(job_id)):
|
|
668
|
+
pipe_state = await self._tmux.is_output_pipe_active(job_id=job_id)
|
|
657
669
|
raise ShellctlServerError(
|
|
658
670
|
500,
|
|
659
671
|
"pipe_failed",
|
|
660
|
-
|
|
672
|
+
self._pipe_ready_failure_message(
|
|
673
|
+
job_id=job_id,
|
|
674
|
+
ready_file=ready_file,
|
|
675
|
+
waited_seconds=waited_seconds,
|
|
676
|
+
pipe_state=pipe_state,
|
|
677
|
+
cause="tmux session exited before the ready-file handshake",
|
|
678
|
+
),
|
|
661
679
|
)
|
|
662
680
|
if anyio.current_time() >= deadline:
|
|
681
|
+
pipe_state = await self._tmux.is_output_pipe_active(job_id=job_id)
|
|
663
682
|
raise ShellctlServerError(
|
|
664
683
|
500,
|
|
665
684
|
"pipe_failed",
|
|
666
|
-
|
|
685
|
+
self._pipe_ready_failure_message(
|
|
686
|
+
job_id=job_id,
|
|
687
|
+
ready_file=ready_file,
|
|
688
|
+
waited_seconds=waited_seconds,
|
|
689
|
+
pipe_state=pipe_state,
|
|
690
|
+
cause="timed out waiting for the sanitize/output pipeline handshake",
|
|
691
|
+
),
|
|
667
692
|
)
|
|
668
693
|
await anyio.sleep(self.config.poll_interval_seconds)
|
|
669
694
|
|
|
695
|
+
def _pipe_ready_failure_message(
|
|
696
|
+
self,
|
|
697
|
+
*,
|
|
698
|
+
job_id: str,
|
|
699
|
+
ready_file: Path,
|
|
700
|
+
waited_seconds: float,
|
|
701
|
+
pipe_state: bool | None,
|
|
702
|
+
cause: str,
|
|
703
|
+
) -> str:
|
|
704
|
+
"""Build a startup error message with enough pipe diagnostics to debug."""
|
|
705
|
+
|
|
706
|
+
return (
|
|
707
|
+
f"Output pipe never became ready for {job_id}: {cause}; "
|
|
708
|
+
f"waited {waited_seconds:.3f}s; "
|
|
709
|
+
f"ready-file={ready_file}; "
|
|
710
|
+
f"tmux #{{pane_pipe}}={self._format_pane_pipe_state(pipe_state)}; "
|
|
711
|
+
f"pipe-error.log={self._pipe_error_log_summary(ready_file.parent)}"
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
def _format_pane_pipe_state(self, pipe_state: bool | None) -> str:
|
|
715
|
+
if pipe_state is True:
|
|
716
|
+
return "1"
|
|
717
|
+
if pipe_state is False:
|
|
718
|
+
return "0"
|
|
719
|
+
return "pane-missing"
|
|
720
|
+
|
|
721
|
+
def _pipe_error_log_summary(self, job_dir: Path) -> str:
|
|
722
|
+
"""Summarize sanitizer stderr without flooding API error responses."""
|
|
723
|
+
|
|
724
|
+
error_log = pipe_error_log_path(job_dir)
|
|
725
|
+
if not error_log.exists():
|
|
726
|
+
return f"{error_log} missing"
|
|
727
|
+
stderr_text = error_log.read_text(encoding="utf-8", errors="replace").strip()
|
|
728
|
+
if not stderr_text:
|
|
729
|
+
return f"{error_log} empty"
|
|
730
|
+
summary = " | ".join(
|
|
731
|
+
line.strip() for line in stderr_text.splitlines() if line.strip()
|
|
732
|
+
)
|
|
733
|
+
if not summary:
|
|
734
|
+
return f"{error_log} contains only whitespace"
|
|
735
|
+
if len(summary) > 240:
|
|
736
|
+
summary = f"{summary[:237]}..."
|
|
737
|
+
return f"{error_log}: {summary}"
|
|
738
|
+
|
|
670
739
|
async def _materialize_status_view(
|
|
671
740
|
self,
|
|
672
741
|
job_id: str,
|
|
@@ -18,6 +18,7 @@ import anyio
|
|
|
18
18
|
|
|
19
19
|
from shell_session_manager.shellctl.server.artifacts import (
|
|
20
20
|
pipe_drained_path,
|
|
21
|
+
pipe_error_log_path,
|
|
21
22
|
pipe_failed_path,
|
|
22
23
|
runner_ended_at_path,
|
|
23
24
|
runner_exit_code_path,
|
|
@@ -182,13 +183,14 @@ class TmuxController:
|
|
|
182
183
|
|
|
183
184
|
For normal exits, the runner now records completion metadata into job
|
|
184
185
|
artifacts and the pipe finalizer commits `runner-exit` only after
|
|
185
|
-
|
|
186
|
+
the lightweight sanitizer reaches EOF and flushes `output.log`
|
|
187
|
+
successfully. Sanitizer stderr is captured into `pipe-error.log` so
|
|
188
|
+
startup timeouts can distinguish slow imports from subprocess crashes.
|
|
186
189
|
"""
|
|
187
190
|
|
|
188
191
|
sanitize_command = self._shell_join(
|
|
189
192
|
(
|
|
190
|
-
*self._config.
|
|
191
|
-
"sanitize-pty",
|
|
193
|
+
*self._config.sanitize_pty_command,
|
|
192
194
|
"--ready-file",
|
|
193
195
|
str(ready_file),
|
|
194
196
|
)
|
|
@@ -205,12 +207,13 @@ class TmuxController:
|
|
|
205
207
|
)
|
|
206
208
|
output_path = shlex.quote(str(job_dir / "output.log"))
|
|
207
209
|
drained_path = shlex.quote(str(pipe_drained_path(job_dir)))
|
|
210
|
+
error_log_path = shlex.quote(str(pipe_error_log_path(job_dir)))
|
|
208
211
|
failed_path = shlex.quote(str(pipe_failed_path(job_dir)))
|
|
209
212
|
exit_code_path = shlex.quote(str(runner_exit_code_path(job_dir)))
|
|
210
213
|
ended_at_path = shlex.quote(str(runner_ended_at_path(job_dir)))
|
|
211
214
|
return " ; ".join(
|
|
212
215
|
[
|
|
213
|
-
f"{sanitize_command} >> {output_path}",
|
|
216
|
+
f"{sanitize_command} >> {output_path} 2> {error_log_path}",
|
|
214
217
|
"sanitize_status=$?",
|
|
215
218
|
(
|
|
216
219
|
'if [ "$sanitize_status" -eq 0 ]; then '
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import ast
|
|
3
4
|
import importlib
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
@@ -29,10 +30,12 @@ from shell_session_manager.shellctl.server import (
|
|
|
29
30
|
from shell_session_manager.shellctl.server.artifacts import (
|
|
30
31
|
JOB_ENV_FILENAME,
|
|
31
32
|
PIPE_DRAINED_FILENAME,
|
|
33
|
+
PIPE_ERROR_LOG_FILENAME,
|
|
32
34
|
PIPE_FAILED_FILENAME,
|
|
33
35
|
RUNNER_ENDED_AT_FILENAME,
|
|
34
36
|
RUNNER_EXIT_CODE_FILENAME,
|
|
35
37
|
job_env_path,
|
|
38
|
+
pipe_error_log_path,
|
|
36
39
|
)
|
|
37
40
|
from shell_session_manager.shellctl.server.tmux import TmuxController
|
|
38
41
|
from shell_session_manager.shellctl.shared import (
|
|
@@ -57,6 +60,8 @@ class FakeTmuxController:
|
|
|
57
60
|
self.pipe_active: dict[str, bool | None] = {}
|
|
58
61
|
self.cleaned: list[str] = []
|
|
59
62
|
self.touch_ready_on_enable = True
|
|
63
|
+
self.pipe_active_on_enable: bool | None = True
|
|
64
|
+
self.pipe_error_log_text: str | None = None
|
|
60
65
|
self.on_send_interrupt: Callable[[str], Awaitable[None]] | None = None
|
|
61
66
|
self.on_send_input: Callable[[str, str], Awaitable[None]] | None = None
|
|
62
67
|
|
|
@@ -82,8 +87,12 @@ class FakeTmuxController:
|
|
|
82
87
|
async def enable_output_pipe(
|
|
83
88
|
self, *, job_id: str, job_dir: Path, ready_file: Path
|
|
84
89
|
) -> None:
|
|
85
|
-
|
|
86
|
-
self.
|
|
90
|
+
self.pipe_active[job_id] = self.pipe_active_on_enable
|
|
91
|
+
if self.pipe_error_log_text is not None:
|
|
92
|
+
pipe_error_log_path(job_dir).write_text(
|
|
93
|
+
self.pipe_error_log_text,
|
|
94
|
+
encoding="utf-8",
|
|
95
|
+
)
|
|
87
96
|
if self.touch_ready_on_enable:
|
|
88
97
|
ready_file.touch()
|
|
89
98
|
|
|
@@ -122,15 +131,17 @@ async def _create_real_service(
|
|
|
122
131
|
tmp_path: Path,
|
|
123
132
|
*,
|
|
124
133
|
shellctl_command: tuple[str, ...] | None = None,
|
|
134
|
+
sanitize_pty_command: tuple[str, ...] | None = None,
|
|
125
135
|
) -> ShellctlService:
|
|
136
|
+
defaults = ShellctlConfig(
|
|
137
|
+
state_dir=tmp_path / "default-state",
|
|
138
|
+
runtime_dir=tmp_path / "default-run",
|
|
139
|
+
)
|
|
126
140
|
config = ShellctlConfig(
|
|
127
141
|
state_dir=tmp_path / "state",
|
|
128
142
|
runtime_dir=tmp_path / "run",
|
|
129
|
-
shellctl_command=shellctl_command
|
|
130
|
-
or
|
|
131
|
-
state_dir=tmp_path / "default-state",
|
|
132
|
-
runtime_dir=tmp_path / "default-run",
|
|
133
|
-
).shellctl_command,
|
|
143
|
+
shellctl_command=shellctl_command or defaults.shellctl_command,
|
|
144
|
+
sanitize_pty_command=sanitize_pty_command or defaults.sanitize_pty_command,
|
|
134
145
|
)
|
|
135
146
|
service = ShellctlService(config)
|
|
136
147
|
await service.initialize()
|
|
@@ -154,9 +165,12 @@ def _write_delayed_sanitize_wrapper(
|
|
|
154
165
|
"import time",
|
|
155
166
|
"from pathlib import Path",
|
|
156
167
|
"",
|
|
157
|
-
|
|
168
|
+
(
|
|
169
|
+
f"REAL = [{sys.executable!r}, '-m', "
|
|
170
|
+
"'shell_session_manager.shellctl.sanitize_pty']"
|
|
171
|
+
),
|
|
158
172
|
"args = sys.argv[1:]",
|
|
159
|
-
"if
|
|
173
|
+
"if '--ready-file' in args:",
|
|
160
174
|
" ready_file = Path(args[args.index('--ready-file') + 1])",
|
|
161
175
|
" ready_file.touch()",
|
|
162
176
|
f" time.sleep({delay_seconds!r})",
|
|
@@ -318,6 +332,12 @@ async def test_run_job_does_not_materialize_fresh_row_to_lost_during_startup_win
|
|
|
318
332
|
await service.initialize_database()
|
|
319
333
|
service._ensure_dir(service.config.jobs_dir)
|
|
320
334
|
fake_tmux.touch_ready_on_enable = False
|
|
335
|
+
service.config = ShellctlConfig(
|
|
336
|
+
state_dir=service.config.state_dir,
|
|
337
|
+
runtime_dir=service.config.runtime_dir,
|
|
338
|
+
poll_interval_seconds=0.001,
|
|
339
|
+
pipe_ready_timeout_seconds=0.01,
|
|
340
|
+
)
|
|
321
341
|
|
|
322
342
|
await service.run_job(
|
|
323
343
|
RunJobRequest(
|
|
@@ -601,10 +621,10 @@ async def test_terminated_job_does_not_emit_python_wrapper_traceback(
|
|
|
601
621
|
async def test_run_job_returns_flushed_output_on_first_terminal_result(
|
|
602
622
|
tmp_path: Path,
|
|
603
623
|
) -> None:
|
|
604
|
-
|
|
624
|
+
sanitize_pty_command = _write_delayed_sanitize_wrapper(tmp_path, delay_seconds=0.2)
|
|
605
625
|
service = await _create_real_service(
|
|
606
626
|
tmp_path,
|
|
607
|
-
|
|
627
|
+
sanitize_pty_command=sanitize_pty_command,
|
|
608
628
|
)
|
|
609
629
|
|
|
610
630
|
try:
|
|
@@ -644,14 +664,14 @@ async def test_run_job_returns_flushed_output_on_first_terminal_result(
|
|
|
644
664
|
async def test_sanitize_failure_does_not_commit_normal_exit(
|
|
645
665
|
tmp_path: Path,
|
|
646
666
|
) -> None:
|
|
647
|
-
|
|
667
|
+
sanitize_pty_command = _write_delayed_sanitize_wrapper(
|
|
648
668
|
tmp_path,
|
|
649
669
|
delay_seconds=0.2,
|
|
650
670
|
sanitize_exit_code=7,
|
|
651
671
|
)
|
|
652
672
|
service = await _create_real_service(
|
|
653
673
|
tmp_path,
|
|
654
|
-
|
|
674
|
+
sanitize_pty_command=sanitize_pty_command,
|
|
655
675
|
)
|
|
656
676
|
|
|
657
677
|
try:
|
|
@@ -1206,6 +1226,7 @@ async def test_run_job_keeps_start_gate_closed_when_pipe_never_becomes_ready(
|
|
|
1206
1226
|
runtime_dir=service.config.runtime_dir,
|
|
1207
1227
|
poll_interval_seconds=0.001,
|
|
1208
1228
|
pipe_monitor_interval_seconds=0.01,
|
|
1229
|
+
pipe_ready_timeout_seconds=0.01,
|
|
1209
1230
|
)
|
|
1210
1231
|
|
|
1211
1232
|
result = await service.run_job(
|
|
@@ -1225,6 +1246,43 @@ async def test_run_job_keeps_start_gate_closed_when_pipe_never_becomes_ready(
|
|
|
1225
1246
|
await service.shutdown()
|
|
1226
1247
|
|
|
1227
1248
|
|
|
1249
|
+
@pytest.mark.anyio
|
|
1250
|
+
async def test_run_job_pipe_ready_timeout_records_diagnostics(tmp_path: Path) -> None:
|
|
1251
|
+
service, fake_tmux = await _create_service(tmp_path)
|
|
1252
|
+
fake_tmux.touch_ready_on_enable = False
|
|
1253
|
+
fake_tmux.pipe_active_on_enable = False
|
|
1254
|
+
fake_tmux.pipe_error_log_text = "Traceback: sanitizer crashed\nsecond line\n"
|
|
1255
|
+
service.config = ShellctlConfig(
|
|
1256
|
+
state_dir=service.config.state_dir,
|
|
1257
|
+
runtime_dir=service.config.runtime_dir,
|
|
1258
|
+
poll_interval_seconds=0.001,
|
|
1259
|
+
pipe_ready_timeout_seconds=0.01,
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
result = await service.run_job(
|
|
1263
|
+
RunJobRequest(
|
|
1264
|
+
script="printf ready\n",
|
|
1265
|
+
cwd=str(tmp_path),
|
|
1266
|
+
terminal=TerminalSize(),
|
|
1267
|
+
timeout=0.05,
|
|
1268
|
+
output_limit=8192,
|
|
1269
|
+
idle_flush_seconds=0.01,
|
|
1270
|
+
)
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
row = await service._get_job_row(result.job_id)
|
|
1274
|
+
ready_file = service.config.jobs_dir / result.job_id / ".pipe-ready"
|
|
1275
|
+
|
|
1276
|
+
assert result.status is JobStatusName.FAILED
|
|
1277
|
+
assert row.message is not None
|
|
1278
|
+
assert "waited " in row.message
|
|
1279
|
+
assert str(ready_file) in row.message
|
|
1280
|
+
assert "tmux #{pane_pipe}=0" in row.message
|
|
1281
|
+
assert PIPE_ERROR_LOG_FILENAME in row.message
|
|
1282
|
+
assert "Traceback: sanitizer crashed | second line" in row.message
|
|
1283
|
+
await service.shutdown()
|
|
1284
|
+
|
|
1285
|
+
|
|
1228
1286
|
@pytest.mark.anyio
|
|
1229
1287
|
async def test_send_input_terminal_race_returns_conflict_not_500(
|
|
1230
1288
|
tmp_path: Path,
|
|
@@ -1558,6 +1616,19 @@ def test_runner_script_records_completion_metadata_without_direct_runner_exit(
|
|
|
1558
1616
|
anyio.run(service.shutdown)
|
|
1559
1617
|
|
|
1560
1618
|
|
|
1619
|
+
def test_shellctl_config_defaults_to_lightweight_sanitize_entrypoint(
|
|
1620
|
+
tmp_path: Path,
|
|
1621
|
+
) -> None:
|
|
1622
|
+
config = ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run")
|
|
1623
|
+
|
|
1624
|
+
assert config.pipe_ready_timeout_seconds == 10.0
|
|
1625
|
+
assert config.sanitize_pty_command == (
|
|
1626
|
+
sys.executable,
|
|
1627
|
+
"-m",
|
|
1628
|
+
"shell_session_manager.shellctl.sanitize_pty",
|
|
1629
|
+
)
|
|
1630
|
+
|
|
1631
|
+
|
|
1561
1632
|
def test_pipe_command_finalizer_commits_runner_exit_after_drain(tmp_path: Path) -> None:
|
|
1562
1633
|
controller = TmuxController(
|
|
1563
1634
|
ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run")
|
|
@@ -1568,17 +1639,88 @@ def test_pipe_command_finalizer_commits_runner_exit_after_drain(tmp_path: Path)
|
|
|
1568
1639
|
ready_file=tmp_path / "state" / "jobs" / "05211530-k7p" / ".pipe-ready",
|
|
1569
1640
|
)
|
|
1570
1641
|
|
|
1571
|
-
assert "
|
|
1642
|
+
assert "shell_session_manager.shellctl.sanitize_pty" in source
|
|
1643
|
+
assert "shell_session_manager.shellctl.server sanitize-pty" not in source
|
|
1572
1644
|
assert PIPE_DRAINED_FILENAME in source
|
|
1645
|
+
assert PIPE_ERROR_LOG_FILENAME in source
|
|
1573
1646
|
assert PIPE_FAILED_FILENAME in source
|
|
1574
1647
|
assert RUNNER_EXIT_CODE_FILENAME in source
|
|
1575
1648
|
assert RUNNER_ENDED_AT_FILENAME in source
|
|
1576
1649
|
assert re.search(r"\brunner-exit\b", source) is not None
|
|
1577
1650
|
assert 'if [ "$sanitize_status" -eq 0 ]' in source
|
|
1578
|
-
assert
|
|
1651
|
+
assert "2>" in source
|
|
1652
|
+
assert source.index("shell_session_manager.shellctl.sanitize_pty") < source.index(
|
|
1653
|
+
PIPE_DRAINED_FILENAME
|
|
1654
|
+
)
|
|
1579
1655
|
assert source.index(PIPE_DRAINED_FILENAME) < source.index("runner-exit")
|
|
1580
1656
|
|
|
1581
1657
|
|
|
1658
|
+
def test_sanitize_pty_module_uses_lightweight_imports_only() -> None:
|
|
1659
|
+
module_path = (
|
|
1660
|
+
Path(__file__).resolve().parents[1]
|
|
1661
|
+
/ "src"
|
|
1662
|
+
/ "shell_session_manager"
|
|
1663
|
+
/ "shellctl"
|
|
1664
|
+
/ "sanitize_pty.py"
|
|
1665
|
+
)
|
|
1666
|
+
tree = ast.parse(module_path.read_text(encoding="utf-8"))
|
|
1667
|
+
|
|
1668
|
+
imports: set[str] = set()
|
|
1669
|
+
for node in ast.walk(tree):
|
|
1670
|
+
if isinstance(node, ast.Import):
|
|
1671
|
+
imports.update(alias.name for alias in node.names)
|
|
1672
|
+
elif isinstance(node, ast.ImportFrom) and node.module is not None:
|
|
1673
|
+
imports.add(node.module)
|
|
1674
|
+
|
|
1675
|
+
assert imports == {
|
|
1676
|
+
"argparse",
|
|
1677
|
+
"pathlib",
|
|
1678
|
+
"sys",
|
|
1679
|
+
"shell_session_manager.shellctl.shared.sanitize",
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
def test_python_m_sanitize_pty_module_touches_ready_file_and_sanitizes_output(
|
|
1684
|
+
tmp_path: Path,
|
|
1685
|
+
) -> None:
|
|
1686
|
+
package_root = Path(__file__).resolve().parents[1]
|
|
1687
|
+
src_path = package_root / "src"
|
|
1688
|
+
env = dict(os.environ)
|
|
1689
|
+
current_pythonpath = env.get("PYTHONPATH")
|
|
1690
|
+
env["PYTHONPATH"] = (
|
|
1691
|
+
f"{src_path}{os.pathsep}{current_pythonpath}"
|
|
1692
|
+
if current_pythonpath
|
|
1693
|
+
else str(src_path)
|
|
1694
|
+
)
|
|
1695
|
+
ready_file = tmp_path / "ready"
|
|
1696
|
+
|
|
1697
|
+
result = subprocess.run(
|
|
1698
|
+
[
|
|
1699
|
+
sys.executable,
|
|
1700
|
+
"-m",
|
|
1701
|
+
"shell_session_manager.shellctl.sanitize_pty",
|
|
1702
|
+
"--ready-file",
|
|
1703
|
+
str(ready_file),
|
|
1704
|
+
],
|
|
1705
|
+
input=b"before\rafter\n",
|
|
1706
|
+
capture_output=True,
|
|
1707
|
+
check=False,
|
|
1708
|
+
cwd=package_root,
|
|
1709
|
+
env=env,
|
|
1710
|
+
)
|
|
1711
|
+
|
|
1712
|
+
assert result.returncode == 0, result.stderr.decode("utf-8", errors="replace")
|
|
1713
|
+
assert ready_file.exists()
|
|
1714
|
+
assert result.stdout.decode("utf-8") == "after\n"
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
def test_server_cli_no_longer_exposes_sanitize_pty_command() -> None:
|
|
1718
|
+
result = CliRunner().invoke(cli, ["sanitize-pty"])
|
|
1719
|
+
|
|
1720
|
+
assert result.exit_code != 0
|
|
1721
|
+
assert "No such command 'sanitize-pty'" in result.output
|
|
1722
|
+
|
|
1723
|
+
|
|
1582
1724
|
def test_runner_exit_cli_accepts_runner_option_contract(tmp_path: Path) -> None:
|
|
1583
1725
|
async def setup_running_job() -> ShellctlConfig:
|
|
1584
1726
|
service, _fake_tmux = await _create_service(tmp_path)
|
|
@@ -1634,5 +1776,5 @@ def test_python_m_shellctl_server_module_entrypoint_shows_cli_help() -> None:
|
|
|
1634
1776
|
)
|
|
1635
1777
|
|
|
1636
1778
|
assert result.returncode == 0, result.stderr
|
|
1637
|
-
assert "sanitize-pty" in result.stdout
|
|
1779
|
+
assert "sanitize-pty" not in result.stdout
|
|
1638
1780
|
assert "runner-exit" in result.stdout
|
|
File without changes
|
|
File without changes
|
{shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/__init__.py
RENAMED
|
File without changes
|
{shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/py.typed
RENAMED
|
File without changes
|
{shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/src/shell_session_manager/session.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/tests/golden_shellctl_sanitize.json
RENAMED
|
File without changes
|
{shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/tests/test_shell_session_autoclose.py
RENAMED
|
File without changes
|
{shell_session_manager-2.2.0 → shell_session_manager-2.2.1}/tests/test_shell_session_manager.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|