SessionSmith 2.1.0__tar.gz → 2.2.0__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.
Files changed (50) hide show
  1. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/PKG-INFO +2 -6
  2. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/__init__.py +1 -1
  3. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/core.py +9 -4
  4. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/error_handling.py +1 -1
  5. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/exceptions.py +44 -2
  6. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/i18n.py +5 -3
  7. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/info.py +0 -1
  8. sessionsmith-2.2.0/SessionSmith/locking.py +385 -0
  9. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/remote_backends.py +49 -0
  10. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/ssm.py +786 -408
  11. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/tracer.py +2 -2
  12. sessionsmith-2.2.0/SessionSmith/validation.py +158 -0
  13. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith.egg-info/PKG-INFO +2 -6
  14. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith.egg-info/SOURCES.txt +7 -0
  15. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/pyproject.toml +57 -1
  16. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/readme.md +1 -0
  17. sessionsmith-2.2.0/setup.py +4 -0
  18. sessionsmith-2.2.0/tests/test_locking.py +454 -0
  19. sessionsmith-2.2.0/tests/test_merge_checkout_features.py +224 -0
  20. sessionsmith-2.2.0/tests/test_security.py +243 -0
  21. sessionsmith-2.2.0/tests/test_ssm_bugfixes.py +239 -0
  22. sessionsmith-2.2.0/tests/test_ssm_e2e.py +466 -0
  23. sessionsmith-2.1.0/setup.py +0 -71
  24. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/LICENSE +0 -0
  25. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/cli.py +0 -0
  26. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/compare.py +0 -0
  27. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/crypto.py +0 -0
  28. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/formats.py +0 -0
  29. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/jupyter_utils.py +0 -0
  30. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/logging_config.py +0 -0
  31. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/manager.py +0 -0
  32. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/py.typed +0 -0
  33. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/resource_manager.py +0 -0
  34. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/serializers.py +0 -0
  35. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/utils.py +0 -0
  36. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/visualizer.py +0 -0
  37. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/visualizer_arrays.py +0 -0
  38. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith/visualizer_generic.py +0 -0
  39. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith.egg-info/dependency_links.txt +0 -0
  40. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith.egg-info/entry_points.txt +0 -0
  41. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith.egg-info/requires.txt +0 -0
  42. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/SessionSmith.egg-info/top_level.txt +0 -0
  43. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/setup.cfg +0 -0
  44. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/tests/test_cli.py +0 -0
  45. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/tests/test_core.py +0 -0
  46. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/tests/test_crypto.py +0 -0
  47. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/tests/test_logging_config.py +0 -0
  48. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/tests/test_remote_backends.py +0 -0
  49. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/tests/test_ssm.py +0 -0
  50. {sessionsmith-2.1.0 → sessionsmith-2.2.0}/tests/test_verify.py +0 -0
@@ -1,9 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SessionSmith
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Git-style session management for Python. Save, restore, and track your variables with ease.
5
- Home-page: https://github.com/yut0takagi/SessionSmith
6
- Author: YutoTAKAGI
7
5
  Author-email: YutoTAKAGI <yutotkg.1040@gmail.com>
8
6
  License: MIT
9
7
  Project-URL: Homepage, https://github.com/yut0takagi/SessionSmith
@@ -56,10 +54,7 @@ Requires-Dist: matplotlib>=3.5.0; extra == "all"
56
54
  Requires-Dist: cryptography>=3.4; extra == "all"
57
55
  Requires-Dist: boto3>=1.26; extra == "all"
58
56
  Requires-Dist: google-cloud-storage>=2.0; extra == "all"
59
- Dynamic: author
60
- Dynamic: home-page
61
57
  Dynamic: license-file
62
- Dynamic: requires-python
63
58
 
64
59
  # SessionSmith
65
60
 
@@ -424,6 +419,7 @@ Python notebookで複数のファイルからインポートしたり、複数
424
419
  - 📈 [アルゴリズムトレーサー](docs/algorithm-tracer.md) - トレース・可視化機能
425
420
  - 🌐 [国際化(i18n)ガイド](docs/i18n-guide.md) - 多言語対応(日本語・英語)
426
421
  - 📚 [APIリファレンス](docs/api-reference.md) - 全APIの詳細
422
+ - ⏱️ [ベンチマーク](benchmarks/README.md) - 大規模セッション・チェックポイント・メモリ計測(issue #31)
427
423
 
428
424
  ## 使用例
429
425
 
@@ -118,7 +118,7 @@ from .tracer import AlgorithmTracer
118
118
  from .utils import verify_session
119
119
  from .visualizer import print_trace_summary, visualize_algorithm_trace
120
120
 
121
- __version__ = "2.1.0"
121
+ __version__ = "2.2.0"
122
122
 
123
123
  # 環境変数からロギングを自動設定(SESSIONSMITH_LOG_LEVEL / SESSIONSMITH_LOG_FILE)
124
124
  try:
@@ -46,9 +46,14 @@ def _validate_file_path(file_path: Union[str, Path]) -> Path:
46
46
  if not isinstance(file_path, (str, Path)):
47
47
  raise TypeError(f"file_path must be str or Path, got {type(file_path).__name__}")
48
48
 
49
- if not str(file_path).strip():
49
+ path_str = str(file_path)
50
+
51
+ if not path_str.strip():
50
52
  raise ValueError("file_path cannot be empty")
51
53
 
54
+ if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in path_str):
55
+ raise ValueError("file_path must not contain control characters")
56
+
52
57
  return Path(file_path)
53
58
 
54
59
 
@@ -540,11 +545,11 @@ def load_session(
540
545
  session = {k: v for k, v in session.items() if k not in exclude}
541
546
 
542
547
  # グローバル変数に更新
543
- loaded_vars: list[str] = []
548
+ loaded_names: list[str] = []
544
549
  for k, v in session.items():
545
550
  try:
546
551
  globals_dict[k] = v
547
- loaded_vars.append(k)
552
+ loaded_names.append(k)
548
553
  if verbose:
549
554
  print(f"Loaded variable: {k} ({type(v).__name__})")
550
555
  except Exception as e:
@@ -552,6 +557,6 @@ def load_session(
552
557
  warnings.warn(f"Failed to load variable '{k}': {str(e)}", UserWarning, stacklevel=2)
553
558
 
554
559
  if verbose:
555
- print(f"Loaded {len(loaded_vars)} variables")
560
+ print(f"Loaded {len(loaded_names)} variables")
556
561
 
557
562
  return session
@@ -118,7 +118,7 @@ def safe_execute(
118
118
  func: Callable[..., T],
119
119
  *args: Any,
120
120
  default: Optional[T] = None,
121
- on_error: Callable[[Exception], Optional[T]] = None,
121
+ on_error: Optional[Callable[[Exception], Optional[T]]] = None,
122
122
  **kwargs: Any
123
123
  ) -> Optional[T]:
124
124
  """
@@ -9,7 +9,8 @@ SessionSmith カスタム例外クラス
9
9
  │ ├── SSMNotInitializedError
10
10
  │ ├── SSMCommitNotFoundError
11
11
  │ ├── SSMNoCommitsError
12
- │ └── SSMConfigError
12
+ │ ├── SSMConfigError
13
+ │ └── SSMLockError
13
14
  ├── SessionError (セッション操作関連)
14
15
  │ ├── SessionSaveError
15
16
  │ ├── SessionLoadError
@@ -134,6 +135,40 @@ class SSMRemoteNotFoundError(SSMError):
134
135
  super().__init__(message, details={"remote_name": remote_name})
135
136
 
136
137
 
138
+ class SSMLockError(SSMError):
139
+ """`.ssm` リポジトリのプロセス間ロック取得がタイムアウトした場合の例外"""
140
+
141
+ def __init__(
142
+ self,
143
+ ssm_path: str,
144
+ holder_pid: Optional[int] = None,
145
+ timeout: Optional[float] = None,
146
+ ):
147
+ self.ssm_path = ssm_path
148
+ self.holder_pid = holder_pid
149
+ self.timeout = timeout
150
+
151
+ if holder_pid:
152
+ holder_desc = f"currently held by PID {holder_pid}"
153
+ else:
154
+ holder_desc = "holder could not be determined"
155
+ timeout_desc = f"{timeout:.1f}s" if timeout is not None else "the configured timeout"
156
+
157
+ message = (
158
+ f"Timed out after {timeout_desc} waiting for the lock on '{ssm_path}' "
159
+ f"({holder_desc}). Another process or thread is likely writing to this "
160
+ f".ssm repository. If no other process is actually using it, the lock "
161
+ f"file ('.lock') may be stale from a crashed process; SessionSmith "
162
+ f"automatically reclaims stale locks once the holder process is "
163
+ f"confirmed dead or the lock exceeds its max age, so retrying shortly "
164
+ f"usually resolves this."
165
+ )
166
+ super().__init__(
167
+ message,
168
+ details={"ssm_path": ssm_path, "holder_pid": holder_pid, "timeout": timeout},
169
+ )
170
+
171
+
137
172
  class SSMMergeConflictError(SSMError):
138
173
  """マージコンフリクトが発生した場合の例外"""
139
174
 
@@ -141,7 +176,14 @@ class SSMMergeConflictError(SSMError):
141
176
  self.branch_name = branch_name
142
177
  self.conflicts = conflicts
143
178
  i18n = _get_i18n()
144
- message = i18n.translate("error.merge_conflict", branch_name=branch_name, count=len(conflicts))
179
+ # コンフリクトした変数名をメッセージ本文にも含める(呼び出し側が
180
+ # str(exc) だけを見ても、どの変数が衝突したか分かるようにするため)
181
+ message = i18n.translate(
182
+ "error.merge_conflict",
183
+ branch_name=branch_name,
184
+ count=len(conflicts),
185
+ vars=", ".join(conflicts),
186
+ )
145
187
  super().__init__(message, details={"branch_name": branch_name, "conflicts": conflicts})
146
188
 
147
189
 
@@ -53,7 +53,7 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
53
53
  "error.remote_no_url": "リモート '{remote_name}' にURLが設定されていません",
54
54
  "error.remote_repository_not_found": "リモートリポジトリが見つかりません: {remote_url}",
55
55
  "error.tag_no_commit": "タグ '{tag_name}' にコミットが設定されていません",
56
- "error.merge_conflict": "マージコンフリクトが発生しました: ブランチ '{branch_name}' で {count} 個のコンフリクト",
56
+ "error.merge_conflict": "マージコンフリクトが発生しました: ブランチ '{branch_name}' で {count} 個のコンフリクト ({vars})",
57
57
 
58
58
  # 警告メッセージ
59
59
  "warn.large_variable": "大きな変数が検出されました: '{name}' ({size_mb:.1f}MB)",
@@ -62,6 +62,7 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
62
62
  "warn.checkpoint_failed": "チェックポイントの保存に失敗しました: {reason}",
63
63
  "warn.continuous_mode_unavailable": "常時記録モードはJupyter/IPython環境でのみ利用可能です",
64
64
  "warn.variable_conflict": "変数名の衝突が検出されました: ファイル '{previous_file}' と '{current_file}' で同じ変数名 ({vars}) が使用されています。複数のファイルから同じ変数名を使用する場合は注意してください。",
65
+ "warn.merge_conflict": "マージコンフリクトを検出しました: ブランチ '{branch_name}' で変数 {vars} が共通祖先から両側で異なる値に変更されています。マージ呼び出し時点でセッションに存在する値が採用されます(last-writer-wins)。",
65
66
 
66
67
  # 情報メッセージ
67
68
  "info.session_saved": "セッションを保存しました: {file_path} ({size:,} bytes, 形式: {format})",
@@ -122,7 +123,7 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
122
123
  "error.remote_no_url": "Remote '{remote_name}' has no URL",
123
124
  "error.remote_repository_not_found": "Remote repository not found: {remote_url}",
124
125
  "error.tag_no_commit": "Tag '{tag_name}' has no commit",
125
- "error.merge_conflict": "Merge conflict: {count} conflicts in branch '{branch_name}'",
126
+ "error.merge_conflict": "Merge conflict: {count} conflicts in branch '{branch_name}' ({vars})",
126
127
 
127
128
  # Warning messages
128
129
  "warn.large_variable": "Large variable detected: '{name}' ({size_mb:.1f}MB)",
@@ -131,6 +132,7 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
131
132
  "warn.checkpoint_failed": "Checkpoint save failed: {reason}",
132
133
  "warn.continuous_mode_unavailable": "Continuous mode is only available in Jupyter/IPython environment",
133
134
  "warn.variable_conflict": "Variable name conflict detected: same variable names ({vars}) used in '{previous_file}' and '{current_file}'. Be careful when using the same variable names from multiple files.",
135
+ "warn.merge_conflict": "Merge conflict detected: variables {vars} were changed differently on both sides since the common ancestor in branch '{branch_name}'. The value currently in the session at merge time will be used (last-writer-wins).",
134
136
  "warn.disk_warning": "Disk space warning: {usage_percent:.1f}% used ({free_mb:.1f}MB free)",
135
137
  "warn.disk_critical": "Disk space critical: {usage_percent:.1f}% used ({free_mb:.1f}MB free)",
136
138
  "warn.memory_warning": "Memory usage warning: {usage_percent:.1f}% used ({used_mb:.1f}MB used, {available_mb:.1f}MB available)",
@@ -210,7 +212,7 @@ def get_language() -> str:
210
212
  if _current_language == Language.AUTO:
211
213
  return detect_language()
212
214
  else:
213
- return _current_language.value
215
+ return str(_current_language.value)
214
216
 
215
217
 
216
218
  def set_language(lang: Union[str, Language], save_to_ssm: bool = True) -> None:
@@ -31,7 +31,6 @@ def _load_session_file(file_path: Union[str, Path], format: Optional[str] = None
31
31
  if not file_path.is_file():
32
32
  raise ValueError(f"'{file_path}' is not a file.")
33
33
 
34
- compression: Optional[str] = None
35
34
  from .formats import detect_format, load_hdf5, load_json, load_msgpack, load_pickle
36
35
 
37
36
  session: dict[str, Any] = {}
@@ -0,0 +1,385 @@
1
+ """
2
+ SessionSmith プロセス間ロック (.ssm リポジトリ単位)
3
+
4
+ 複数のプロセス(および同一プロセス内の複数スレッド)が同じ `.ssm`
5
+ リポジトリに同時にアクセスしても、履歴(HEAD / ブランチ参照 / コミット /
6
+ オブジェクト)が破損しないようにするための排他ロックを提供します。
7
+
8
+ 設計方針
9
+ --------
10
+ 1. **クロスプラットフォーム**: POSIX / Windows のどちらでも同じ意味で
11
+ 動作する標準ライブラリのプリミティブのみを使用します。
12
+ ``os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)`` は、対象パスに
13
+ ファイルが存在しない場合に限りアトミックに新規作成する、という保証が
14
+ POSIX と Windows の両方の `open()`/`CreateFile()` 実装で成り立つため、
15
+ これをロック取得のプリミティブとして採用しています
16
+ (`fcntl`/`msvcrt` のようなOS別APIを避けられる)。
17
+
18
+ 2. **ロックファイル**: `<ssm_path>/.lock` に、保持者の PID と取得時刻を
19
+ JSON で書き込みます。診断(誰が保持しているか)と stale ロックの
20
+ 判定に使います。
21
+
22
+ 3. **タイムアウト**: 取得できない場合はポーリング(デフォルト 50ms間隔)
23
+ で再試行し、`timeout` 秒(デフォルト 10 秒)を超えると
24
+ `SSMLockError` を送出します。メッセージには対象の `.ssm` パスと、
25
+ 分かる場合は現在の保持者 PID を含めます。
26
+
27
+ 4. **stale ロックの回収**: ロックファイルが残っていても、それが
28
+ 「もう有効でない残骸」と判断できる場合のみ回収(削除して取得し直し)
29
+ します。判定は保持者 PID の生存状態を3値(dead / alive / unknown)で
30
+ 見て行います(詳細は ``_reclaim_if_stale`` を参照):
31
+
32
+ - **自プロセスの PID** の残骸 → 回収(前回 release の unlink 失敗や
33
+ PID 再利用による残骸。自己ロックアウトの回避)。
34
+ - **死亡が確認できた保持者**(POSIX: ``os.kill(pid, 0)`` が
35
+ `ProcessLookupError`)→ 回収。
36
+ - **生存が確認できた保持者** → **決して回収しない**。mtime が古くても、
37
+ 大きな pull/merge/checkpoint 等で長時間ロックを保持している正当な
38
+ 保持者を横取りして `.ssm` を破損させないため、待機/タイムアウトする。
39
+ - **生死が判定できない場合**(PID 不明、Windows で ``os.kill`` を生存
40
+ 判定に使わない方針、権限不足 等)→ ``STALE_LOCK_MAX_AGE`` 秒より
41
+ 古いロックファイルを stale とみなす **年齢ベースのフォールバック**を
42
+ 適用する。この age ベースの回収は「生死判定不能」な場合に **限る**。
43
+
44
+ Windows では `os.kill(pid, 0)` が本来の「シグナル 0 での生存確認」
45
+ としては使えない(`TerminateProcess` に化けてしまう危険がある)ため、
46
+ PID の生死判定は POSIX でのみ行い、Windows は上記の unknown 経路
47
+ (age フォールバック)を使います。
48
+
49
+ 5. **再入可能性(同一プロセス内)**: `.ssm` パスごとに 1 つの
50
+ `threading.RLock` をモジュールレベルのレジストリで保持します。
51
+ ある公開メソッド(例: `checkout_branch`)がロックを保持したまま、
52
+ 内部で別の公開メソッド(例: `checkout`)を呼び出しても、
53
+ **同じスレッド**であれば `RLock` の再入性によって即座に
54
+ ロックを取得でき、デッドロックしません。
55
+ 一方、**別スレッド**(例: バックグラウンドのチェックポイントスレッド)
56
+ が同時に取得しようとした場合は、`RLock` がそのスレッド間できちんと
57
+ 排他するため、待機(またはタイムアウト)します。
58
+
59
+ 実際の OS レベルのロックファイルは、その `RLock` の
60
+ 「そのスレッドにとって最も外側の取得」(再入カウントが 0→1)の
61
+ タイミングでのみ作成し、「最も外側の解放」(1→0)のタイミングでのみ
62
+ 削除します。つまり:
63
+
64
+ - プロセス内の複数スレッド間の排他 → `threading.RLock`
65
+ - プロセス間の排他 → `.lock` ファイル(RLock がスレッドを1つに
66
+ 絞り込んだ後、そのスレッドだけが `.lock` ファイルを操作する)
67
+
68
+ この二層構造により、"file lock は常に1スレッドしか触らない" ことが
69
+ 保証され、OS レベルロックの操作自体をスレッドセーフにする追加の
70
+ ロックは不要です。
71
+ """
72
+
73
+ import json
74
+ import logging
75
+ import os
76
+ import threading
77
+ import time
78
+ from pathlib import Path
79
+ from typing import Optional, Union
80
+
81
+ from .exceptions import SSMLockError
82
+
83
+ logger = logging.getLogger("SessionSmith.locking")
84
+ logger.addHandler(logging.NullHandler())
85
+
86
+ # ロック取得のデフォルトタイムアウト(秒)
87
+ DEFAULT_TIMEOUT = 10.0
88
+
89
+ # stale ロックとみなす最大経過時間(秒)。
90
+ # 【重要】これは「保持者の生死が判定できない場合のみ」のフォールバック。
91
+ # 生存が確認できた保持者のロックは、どれだけ古くても age では奪わない
92
+ # (正当な長時間保持者の横取り=.ssm 破損を防ぐため)。
93
+ STALE_LOCK_MAX_AGE = 120.0
94
+
95
+ # 取得できなかった場合の再試行間隔(秒)
96
+ _POLL_INTERVAL = 0.05
97
+
98
+ # ロックファイル削除のリトライ回数と基準バックオフ(秒)
99
+ _UNLINK_RETRIES = 3
100
+ _UNLINK_BACKOFF = 0.01
101
+
102
+ LOCK_FILENAME = ".lock"
103
+
104
+ # .ssm パス(正規化済み文字列)ごとの threading.RLock レジストリ。
105
+ # 複数の ProcessLock インスタンス(毎回 `with self._lock():` で新規生成
106
+ # される)が、同じ .ssm パスに対しては必ず同じ RLock を共有するための
107
+ # もの。レジストリ自体への同時書き込みは _registry_guard で保護する。
108
+ _registry_guard = threading.Lock()
109
+ _thread_rlocks: dict[str, threading.RLock] = {}
110
+ _reentry_counts: dict[str, int] = {}
111
+
112
+
113
+ def _get_rlock(key: str) -> threading.RLock:
114
+ """指定キーに対応する(プロセス内で共有される)RLock を取得する"""
115
+ with _registry_guard:
116
+ rlock = _thread_rlocks.get(key)
117
+ if rlock is None:
118
+ rlock = threading.RLock()
119
+ _thread_rlocks[key] = rlock
120
+ return rlock
121
+
122
+
123
+ def _pid_liveness(pid: Optional[int]) -> str:
124
+ """
125
+ PID の生存状態を3値で返す: ``"dead"`` / ``"alive"`` / ``"unknown"``。
126
+
127
+ - ``"dead"`` : プロセスが存在しないと**確認できた**(安全に回収してよい)
128
+ - ``"alive"`` : プロセスが存在すると**確認できた**(正当な保持者。回収禁止)
129
+ - ``"unknown"``: 生死を**判定できない**(回収可否は age フォールバックで判断)
130
+
131
+ POSIX でのみ ``os.kill(pid, 0)`` による生存確認を行う。Windows では
132
+ `os.kill` がシグナル配送ではなく `TerminateProcess` にマップされて
133
+ おり、シグナル 0 を安全に「生存確認のみ」に使えないため、判定せず
134
+ ``"unknown"`` を返す(呼び出し側が age ベースにフォールバックする)。
135
+ PID が None・0・負値の場合も、安全のため生死判定に使わず ``"unknown"``。
136
+ """
137
+ if pid is None or os.name == "nt":
138
+ return "unknown"
139
+ if pid <= 0:
140
+ # os.kill(0/-1, sig) はプロセスグループへの送信になり危険なので触らない
141
+ return "unknown"
142
+ try:
143
+ os.kill(pid, 0)
144
+ except ProcessLookupError:
145
+ return "dead"
146
+ except PermissionError:
147
+ # プロセスは存在するが、こちらにシグナルを送る権限がない → 生存確認できた
148
+ return "alive"
149
+ except OSError:
150
+ # その他の理由で判定できない
151
+ return "unknown"
152
+ else:
153
+ return "alive"
154
+
155
+
156
+ class ProcessLock:
157
+ """
158
+ `.ssm` リポジトリ単位のプロセス間・スレッド間排他ロック。
159
+
160
+ 使い方::
161
+
162
+ with ProcessLock(ssm_path, timeout=10.0):
163
+ ... HEAD / branches / commits / objects を読み書き ...
164
+
165
+ Args:
166
+ ssm_path: `.ssm` ディレクトリのパス
167
+ timeout: 取得を待つ最大秒数(デフォルト 10 秒)
168
+ poll_interval: リトライ間隔(秒)
169
+ """
170
+
171
+ def __init__(
172
+ self,
173
+ ssm_path: Union[str, Path],
174
+ timeout: float = DEFAULT_TIMEOUT,
175
+ poll_interval: float = _POLL_INTERVAL,
176
+ ):
177
+ self.ssm_path = Path(ssm_path)
178
+ self.lock_path = self.ssm_path / LOCK_FILENAME
179
+ self.timeout = timeout
180
+ self.poll_interval = poll_interval
181
+ # シンボリックリンクや相対/絶対パスの違いを吸収し、同じ物理ディレクトリが
182
+ # 常に同じキーにマップされるようにする(存在しなくても resolve() は失敗しない)
183
+ self._key = str(self.ssm_path.resolve())
184
+ self._rlock = _get_rlock(self._key)
185
+ self._rlock_acquired = False
186
+ self._holds_os_lock = False
187
+
188
+ def __enter__(self) -> "ProcessLock":
189
+ deadline = time.monotonic() + self.timeout
190
+ incremented = False
191
+
192
+ # __enter__ 全体を BaseException で保護する。KeyboardInterrupt や
193
+ # SystemExit(ssm.py の signal handler 経由で commit 中などに発生し得る)
194
+ # が取得の途中で飛んでも、__exit__ は呼ばれない。ここで確保済みの
195
+ # RLock / OS ロック / reentry カウントを確実に巻き戻し、部分取得状態を
196
+ # 残さないことで、その .ssm パスへの以降のアクセスが永久に詰まる
197
+ # (RLock リーク)のを防ぐ。
198
+ try:
199
+ remaining = max(0.0, deadline - time.monotonic())
200
+ acquired = self._rlock.acquire(timeout=remaining)
201
+ if not acquired:
202
+ # 同一プロセス内の別スレッドがロックを保持している。
203
+ # ロックファイルが読めない場合の保持者は「不明(None)」とする
204
+ # (待機側自身の PID を既定にすると誤解を招くため)。
205
+ holder = self._read_holder_info()
206
+ raise SSMLockError(
207
+ str(self.ssm_path),
208
+ holder_pid=holder.get("pid") if holder else None,
209
+ timeout=self.timeout,
210
+ )
211
+ self._rlock_acquired = True
212
+
213
+ with _registry_guard:
214
+ count = _reentry_counts.get(self._key, 0)
215
+
216
+ if count == 0:
217
+ # このスレッドにとって最も外側の取得 → OS レベルのロックファイルを取る
218
+ self._acquire_os_lock(deadline)
219
+ self._holds_os_lock = True
220
+
221
+ with _registry_guard:
222
+ _reentry_counts[self._key] = _reentry_counts.get(self._key, 0) + 1
223
+ incremented = True
224
+
225
+ return self
226
+ except BaseException:
227
+ # 途中まで確保したものを、取得と逆順に巻き戻す
228
+ if incremented:
229
+ with _registry_guard:
230
+ _reentry_counts[self._key] = max(
231
+ 0, _reentry_counts.get(self._key, 1) - 1
232
+ )
233
+ if self._holds_os_lock:
234
+ self._release_os_lock()
235
+ self._holds_os_lock = False
236
+ if self._rlock_acquired:
237
+ self._rlock_acquired = False
238
+ self._rlock.release()
239
+ raise
240
+
241
+ def __exit__(self, exc_type, exc, tb) -> None:
242
+ try:
243
+ with _registry_guard:
244
+ count = max(0, _reentry_counts.get(self._key, 1) - 1)
245
+ _reentry_counts[self._key] = count
246
+
247
+ if count == 0 and self._holds_os_lock:
248
+ self._release_os_lock()
249
+ self._holds_os_lock = False
250
+ finally:
251
+ if self._rlock_acquired:
252
+ self._rlock_acquired = False
253
+ self._rlock.release()
254
+ return None
255
+
256
+ # ------------------------------------------------------------------
257
+ # OS レベルのロックファイル操作(この時点で呼び出しスレッドは、
258
+ # このプロセス内で当該 .ssm パスに対する唯一の候補者であることが
259
+ # RLock によって保証されている)
260
+ # ------------------------------------------------------------------
261
+
262
+ def _acquire_os_lock(self, deadline: float) -> None:
263
+ while True:
264
+ try:
265
+ fd = os.open(str(self.lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
266
+ try:
267
+ payload = json.dumps(
268
+ {"pid": os.getpid(), "started_at": time.time()}
269
+ ).encode("utf-8")
270
+ os.write(fd, payload)
271
+ finally:
272
+ os.close(fd)
273
+ return
274
+ except FileExistsError:
275
+ if self._reclaim_if_stale():
276
+ # stale ロックを消せたので即座に取得し直す
277
+ continue
278
+ if time.monotonic() >= deadline:
279
+ holder = self._read_holder_info()
280
+ raise SSMLockError(
281
+ str(self.ssm_path),
282
+ holder_pid=holder.get("pid") if holder else None,
283
+ timeout=self.timeout,
284
+ )
285
+ time.sleep(min(self.poll_interval, max(0.0, deadline - time.monotonic())) or self.poll_interval)
286
+
287
+ def _release_os_lock(self) -> None:
288
+ # 一時的な unlink 失敗(AV/バックアップによる一時ロック等)を数回リトライ。
289
+ # 全 OSError を黙って捨てると、自PIDのロックファイルが残った際に
290
+ # 生存判定が alive を返してしまい、次回取得が age 期限まで待たされる
291
+ # (自己ロックアウト)。取得側は own-PID 残骸を回収できるようにして
292
+ # あるが、それでも最終失敗時は必ず warning を出す。
293
+ last_err: Optional[OSError] = None
294
+ for attempt in range(_UNLINK_RETRIES):
295
+ try:
296
+ os.unlink(self.lock_path)
297
+ return
298
+ except FileNotFoundError:
299
+ return
300
+ except OSError as e:
301
+ last_err = e
302
+ time.sleep(_UNLINK_BACKOFF * (attempt + 1))
303
+ logger.warning(
304
+ "Failed to remove lock file %s after %d attempts: %s. "
305
+ "A stale lock file may remain; it will be reclaimed on the next "
306
+ "acquisition (as an own-PID remnant, once the holder is confirmed "
307
+ "dead, or after STALE_LOCK_MAX_AGE).",
308
+ self.lock_path,
309
+ _UNLINK_RETRIES,
310
+ last_err,
311
+ )
312
+
313
+ def _read_holder_info(self) -> Optional[dict]:
314
+ try:
315
+ raw = self.lock_path.read_text(encoding="utf-8")
316
+ data = json.loads(raw)
317
+ except (OSError, ValueError):
318
+ return None
319
+ return data if isinstance(data, dict) else None
320
+
321
+ def _reclaim_if_stale(self) -> bool:
322
+ """
323
+ ロックファイルが stale(もう有効でない残骸)と判断できれば削除して
324
+ True を返す。有効な保持者のロックは決して奪わない。
325
+
326
+ 判定順(重要):
327
+
328
+ 1. **保持者 PID == 自プロセスの PID** → 残骸として回収。
329
+ この関数が呼ばれる時点で、呼び出しスレッドは当該 .ssm パスの
330
+ RLock を保持しており(`__enter__` 参照)、かつ reentry カウントが
331
+ 0(=このプロセス内でまだ OS ロックを持っていない)。したがって
332
+ 同一プロセス内の他スレッドが正当に OS ロックを保持していることは
333
+ あり得ず、自PIDのロックファイルは「前回 release の unlink 失敗で
334
+ 残った残骸」か「その PID を再利用した=元の保持者は死んでいる」の
335
+ いずれか。どちらも安全に回収できる(自己ロックアウトの回避)。
336
+
337
+ 2. **生存が確認できた保持者(liveness == "alive")** → 回収しない。
338
+ mtime がどれだけ古くても、正当な長時間保持者(大きな
339
+ pull/merge/checkpoint)を横取りして .ssm を破損させてはならない。
340
+ 呼び出し側は待機し、必要ならタイムアウトして SSMLockError になる。
341
+
342
+ 3. **死んでいると確認できた保持者(liveness == "dead")** → 回収。
343
+
344
+ 4. **生死を判定できない(liveness == "unknown")** → age フォールバック。
345
+ mtime が STALE_LOCK_MAX_AGE より古ければ回収する。この age ベースの
346
+ 判定は、生存が判定不能な場合(PID不明・Windows・権限不足 等)に
347
+ **限って**適用される。
348
+ """
349
+ info = self._read_holder_info()
350
+ pid = info.get("pid") if info else None
351
+
352
+ # 1. 自プロセスの残骸ロック(自己ロックアウトの回避)
353
+ if pid is not None and pid == os.getpid():
354
+ return self._force_remove()
355
+
356
+ liveness = _pid_liveness(pid)
357
+
358
+ # 3. 死亡確認済み → 回収
359
+ if liveness == "dead":
360
+ return self._force_remove()
361
+
362
+ # 2. 生存確認済み → 決して age で奪わない
363
+ if liveness == "alive":
364
+ return False
365
+
366
+ # 4. 生死判定不能な場合のみ age フォールバック
367
+ try:
368
+ age = time.time() - self.lock_path.stat().st_mtime
369
+ except OSError:
370
+ # 既に他プロセスが削除済み
371
+ return True
372
+
373
+ if age > STALE_LOCK_MAX_AGE:
374
+ return self._force_remove()
375
+
376
+ return False
377
+
378
+ def _force_remove(self) -> bool:
379
+ try:
380
+ os.unlink(self.lock_path)
381
+ return True
382
+ except FileNotFoundError:
383
+ return True
384
+ except OSError:
385
+ return False
@@ -31,11 +31,18 @@ from pathlib import Path
31
31
  from typing import Optional
32
32
  from urllib.parse import urlparse
33
33
 
34
+ from .exceptions import ValidationError
35
+ from .validation import _has_control_chars
36
+
34
37
  logger = logging.getLogger("SessionSmith.remote")
35
38
 
36
39
  # 全ファイル一覧を保持するマニフェスト(HTTP のような一覧不可のバックエンド用)
37
40
  MANIFEST_NAME = "manifest.json"
38
41
 
42
+ # remote_add() などで許可するリモート URL のスキーム。
43
+ # スキームなし(ローカルパス)・Windows ドライブレター(例: "C:")も別途許可する。
44
+ SUPPORTED_REMOTE_SCHEMES = frozenset({"file", "s3", "gs", "gcs", "http", "https"})
45
+
39
46
 
40
47
  class RemoteBackendError(Exception):
41
48
  """リモートバックエンドに関するエラー"""
@@ -239,6 +246,48 @@ class HTTPBackend(RemoteBackend):
239
246
  return files
240
247
 
241
248
 
249
+ def validate_remote_url(url: str) -> str:
250
+ """
251
+ リモート URL のスキームが対応済みか検証します(``remote_add`` 時の早期検証用)。
252
+
253
+ ローカルパス(スキームなし。Windows ドライブレターを含む)と
254
+ ``file://`` / ``s3://`` / ``gs://`` (``gcs://``) / ``http(s)://`` を許可します。
255
+ それ以外のスキーム(``ftp://``, ``javascript:`` など)は拒否します。
256
+
257
+ ``get_backend()`` は push/pull 実行時に同様の判定を行い ``RemoteBackendError``
258
+ を送出しますが、こちらは ``remote_add`` の時点で早期に、かつ既存の
259
+ ``ValidationError`` で失敗させるためのものです。
260
+
261
+ Args:
262
+ url: リモート URL
263
+
264
+ Returns:
265
+ str: 検証済みの URL(そのまま返す)
266
+
267
+ Raises:
268
+ ValidationError: URL が空、制御文字を含む、または未対応のスキームの場合
269
+ """
270
+ if not isinstance(url, str) or not url.strip():
271
+ raise ValidationError("url", "Remote URL must not be empty", url)
272
+
273
+ if _has_control_chars(url):
274
+ raise ValidationError("url", "Remote URL must not contain control characters", url)
275
+
276
+ parsed = urlparse(url)
277
+ scheme = parsed.scheme.lower()
278
+
279
+ # スキームなし = ローカルパス、1文字 = Windows ドライブレター(C: など)
280
+ if scheme == "" or len(scheme) == 1 or scheme in SUPPORTED_REMOTE_SCHEMES:
281
+ return url
282
+
283
+ raise ValidationError(
284
+ "url",
285
+ f"Unsupported remote scheme: '{scheme}://' "
286
+ "(supported: local path, file, s3, gs/gcs, http, https)",
287
+ url,
288
+ )
289
+
290
+
242
291
  def get_backend(url: str, *, local_ssm_dirname: str = ".ssm") -> RemoteBackend:
243
292
  """
244
293
  URL からリモートバックエンドを生成します。