Skip to content

Commit 5077c6c

Browse files
authored
Merge pull request SeemSeam#132 from LeoLin990405/fix/ensure-pane-stale-id
fix: verify title marker in ensure_pane() fast path (SeemSeam#93)
2 parents cb33b2c + 74328af commit 5077c6c

9 files changed

Lines changed: 278 additions & 16 deletions

lib/baskd_session.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,24 @@ def ensure_pane(self) -> Tuple[bool, str]:
8888
return False, "Terminal backend not available"
8989

9090
pane_id = self.pane_id
91+
marker = self.pane_title_marker
92+
resolver = getattr(backend, "find_pane_by_title_marker", None)
93+
9194
if pane_id and backend.is_alive(pane_id):
95+
if marker and callable(resolver):
96+
try:
97+
resolved = resolver(marker)
98+
if resolved and str(resolved) != str(pane_id) and backend.is_alive(str(resolved)):
99+
self.data["pane_id"] = str(resolved)
100+
self.data["updated_at"] = _now_str()
101+
self._write_back()
102+
self._attach_pane_log(backend, str(resolved))
103+
return True, str(resolved)
104+
except Exception:
105+
pass
92106
self._attach_pane_log(backend, pane_id)
93107
return True, pane_id
94108

95-
marker = self.pane_title_marker
96-
resolver = getattr(backend, "find_pane_by_title_marker", None)
97109
if marker and callable(resolver):
98110
resolved = resolver(marker)
99111
if resolved and backend.is_alive(str(resolved)):

lib/caskd_session.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,24 @@ def ensure_pane(self) -> Tuple[bool, str]:
9292
return False, "Terminal backend not available"
9393

9494
pane_id = self.pane_id
95+
marker = self.pane_title_marker
96+
resolver = getattr(backend, "find_pane_by_title_marker", None)
97+
9598
if pane_id and backend.is_alive(pane_id):
99+
if marker and callable(resolver):
100+
try:
101+
resolved = resolver(marker)
102+
if resolved and str(resolved) != str(pane_id) and backend.is_alive(str(resolved)):
103+
self.data["pane_id"] = str(resolved)
104+
self.data["updated_at"] = _now_str()
105+
self._write_back()
106+
self._attach_pane_log(backend, str(resolved))
107+
return True, str(resolved)
108+
except Exception:
109+
pass
96110
self._attach_pane_log(backend, pane_id)
97111
return True, pane_id
98112

99-
marker = self.pane_title_marker
100-
resolver = getattr(backend, "find_pane_by_title_marker", None)
101113
resolved: Optional[str] = None
102114
if marker and callable(resolver):
103115
resolved = resolver(marker)

lib/daskd_session.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,24 @@ def ensure_pane(self) -> Tuple[bool, str]:
9090
return False, "Terminal backend not available"
9191

9292
pane_id = self.pane_id
93+
marker = self.pane_title_marker
94+
resolver = getattr(backend, "find_pane_by_title_marker", None)
95+
9396
if pane_id and backend.is_alive(pane_id):
97+
if marker and callable(resolver):
98+
try:
99+
resolved = resolver(marker)
100+
if resolved and str(resolved) != str(pane_id) and backend.is_alive(str(resolved)):
101+
self.data["pane_id"] = str(resolved)
102+
self.data["updated_at"] = _now_str()
103+
self._write_back()
104+
self._attach_pane_log(backend, str(resolved))
105+
return True, str(resolved)
106+
except Exception:
107+
pass
94108
self._attach_pane_log(backend, pane_id)
95109
return True, pane_id
96110

97-
marker = self.pane_title_marker
98-
resolver = getattr(backend, "find_pane_by_title_marker", None)
99111
if marker and callable(resolver):
100112
resolved = resolver(marker)
101113
if resolved and backend.is_alive(str(resolved)):

lib/gaskd_session.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,26 @@ def ensure_pane(self) -> Tuple[bool, str]:
9090
return False, "Terminal backend not available"
9191

9292
pane_id = self.pane_id
93+
marker = self.pane_title_marker
94+
resolver = getattr(backend, "find_pane_by_title_marker", None)
95+
9396
if pane_id and backend.is_alive(pane_id):
97+
# Verify title marker: if marker resolves to a different pane,
98+
# the cached pane_id is stale (tmux recycled the ID).
99+
if marker and callable(resolver):
100+
try:
101+
resolved = resolver(marker)
102+
if resolved and str(resolved) != str(pane_id) and backend.is_alive(str(resolved)):
103+
self.data["pane_id"] = str(resolved)
104+
self.data["updated_at"] = _now_str()
105+
self._write_back()
106+
self._attach_pane_log(backend, str(resolved))
107+
return True, str(resolved)
108+
except Exception:
109+
pass
94110
self._attach_pane_log(backend, pane_id)
95111
return True, pane_id
96112

97-
marker = self.pane_title_marker
98-
resolver = getattr(backend, "find_pane_by_title_marker", None)
99113
if marker and callable(resolver):
100114
resolved = resolver(marker)
101115
if resolved and backend.is_alive(str(resolved)):

lib/haskd_session.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,24 @@ def ensure_pane(self) -> Tuple[bool, str]:
8888
return False, "Terminal backend not available"
8989

9090
pane_id = self.pane_id
91+
marker = self.pane_title_marker
92+
resolver = getattr(backend, "find_pane_by_title_marker", None)
93+
9194
if pane_id and backend.is_alive(pane_id):
95+
if marker and callable(resolver):
96+
try:
97+
resolved = resolver(marker)
98+
if resolved and str(resolved) != str(pane_id) and backend.is_alive(str(resolved)):
99+
self.data["pane_id"] = str(resolved)
100+
self.data["updated_at"] = _now_str()
101+
self._write_back()
102+
self._attach_pane_log(backend, str(resolved))
103+
return True, str(resolved)
104+
except Exception:
105+
pass
92106
self._attach_pane_log(backend, pane_id)
93107
return True, pane_id
94108

95-
marker = self.pane_title_marker
96-
resolver = getattr(backend, "find_pane_by_title_marker", None)
97109
if marker and callable(resolver):
98110
resolved = resolver(marker)
99111
if resolved and backend.is_alive(str(resolved)):

lib/laskd_session.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,12 +169,24 @@ def ensure_pane(self) -> Tuple[bool, str]:
169169
return False, "Terminal backend not available"
170170

171171
pane_id = self.pane_id
172+
marker = self.pane_title_marker
173+
resolver = getattr(backend, "find_pane_by_title_marker", None)
174+
172175
if pane_id and backend.is_alive(pane_id):
176+
if marker and callable(resolver):
177+
try:
178+
resolved = resolver(marker)
179+
if resolved and str(resolved) != str(pane_id) and backend.is_alive(str(resolved)):
180+
self.data["pane_id"] = str(resolved)
181+
self.data["updated_at"] = _now_str()
182+
self._write_back()
183+
self._attach_pane_log(backend, str(resolved))
184+
return True, str(resolved)
185+
except Exception:
186+
pass
173187
self._attach_pane_log(backend, pane_id)
174188
return True, pane_id
175189

176-
marker = self.pane_title_marker
177-
resolver = getattr(backend, "find_pane_by_title_marker", None)
178190
if marker and callable(resolver):
179191
resolved = resolver(marker)
180192
if resolved and backend.is_alive(str(resolved)):

lib/oaskd_session.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,24 @@ def ensure_pane(self) -> Tuple[bool, str]:
116116
return False, "Terminal backend not available"
117117

118118
pane_id = self.pane_id
119+
marker = self.pane_title_marker
120+
resolver = getattr(backend, "find_pane_by_title_marker", None)
121+
119122
if pane_id and backend.is_alive(pane_id):
123+
if marker and callable(resolver):
124+
try:
125+
resolved = resolver(marker)
126+
if resolved and str(resolved) != str(pane_id) and backend.is_alive(str(resolved)):
127+
self.data["pane_id"] = str(resolved)
128+
self.data["updated_at"] = _now_str()
129+
self._write_back()
130+
self._attach_pane_log(backend, str(resolved))
131+
return True, str(resolved)
132+
except Exception:
133+
pass
120134
self._attach_pane_log(backend, pane_id)
121135
return True, pane_id
122136

123-
marker = self.pane_title_marker
124-
resolver = getattr(backend, "find_pane_by_title_marker", None)
125137
if marker and callable(resolver):
126138
resolved = resolver(marker)
127139
if resolved and backend.is_alive(str(resolved)):

lib/qaskd_session.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,24 @@ def ensure_pane(self) -> Tuple[bool, str]:
8888
return False, "Terminal backend not available"
8989

9090
pane_id = self.pane_id
91+
marker = self.pane_title_marker
92+
resolver = getattr(backend, "find_pane_by_title_marker", None)
93+
9194
if pane_id and backend.is_alive(pane_id):
95+
if marker and callable(resolver):
96+
try:
97+
resolved = resolver(marker)
98+
if resolved and str(resolved) != str(pane_id) and backend.is_alive(str(resolved)):
99+
self.data["pane_id"] = str(resolved)
100+
self.data["updated_at"] = _now_str()
101+
self._write_back()
102+
self._attach_pane_log(backend, str(resolved))
103+
return True, str(resolved)
104+
except Exception:
105+
pass
92106
self._attach_pane_log(backend, pane_id)
93107
return True, pane_id
94108

95-
marker = self.pane_title_marker
96-
resolver = getattr(backend, "find_pane_by_title_marker", None)
97109
if marker and callable(resolver):
98110
resolved = resolver(marker)
99111
if resolved and backend.is_alive(str(resolved)):

test/test_ensure_pane_stale.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Tests for ensure_pane() title marker verification in the fast path.
2+
3+
Verifies that when a cached pane_id is alive but the title marker resolves
4+
to a different pane (tmux ID recycling), ensure_pane() updates to the
5+
correct pane instead of routing messages to the wrong process.
6+
"""
7+
from __future__ import annotations
8+
9+
import json
10+
from pathlib import Path
11+
from typing import Optional
12+
13+
import pytest
14+
15+
from gaskd_session import GeminiProjectSession
16+
from caskd_session import CodexProjectSession
17+
from oaskd_session import OpenCodeProjectSession
18+
from daskd_session import DroidProjectSession
19+
from baskd_session import CodebuddyProjectSession
20+
from haskd_session import CopilotProjectSession
21+
from laskd_session import ClaudeProjectSession
22+
from qaskd_session import QwenProjectSession
23+
24+
25+
class _FakeBackend:
26+
"""Fake terminal backend for testing ensure_pane()."""
27+
28+
def __init__(
29+
self,
30+
alive_panes: set[str],
31+
marker_map: Optional[dict[str, str]] = None,
32+
):
33+
self.alive_panes = alive_panes
34+
self.marker_map = marker_map or {}
35+
self.attached: list[str] = []
36+
37+
def is_alive(self, pane_id: str) -> bool:
38+
return pane_id in self.alive_panes
39+
40+
def find_pane_by_title_marker(self, marker: str) -> Optional[str]:
41+
return self.marker_map.get(marker)
42+
43+
def ensure_pane_log(self, pane_id: str) -> None:
44+
self.attached.append(pane_id)
45+
46+
47+
def _write_session(path: Path, data: dict) -> None:
48+
path.parent.mkdir(parents=True, exist_ok=True)
49+
path.write_text(json.dumps(data), encoding="utf-8")
50+
51+
52+
def _make_session(cls, tmp_path: Path, pane_id: str, marker: str, backend: _FakeBackend):
53+
"""Create a session object with a fake backend."""
54+
session_file = tmp_path / ".session"
55+
data = {
56+
"pane_id": pane_id,
57+
"pane_title_marker": marker,
58+
"terminal": "tmux",
59+
"work_dir": str(tmp_path),
60+
}
61+
_write_session(session_file, data)
62+
session = cls.__new__(cls)
63+
session.data = data
64+
session.session_file = session_file
65+
session._backend = backend
66+
67+
# Override backend() to return our fake
68+
session.backend = lambda: backend
69+
# Override _attach_pane_log to be a no-op
70+
session._attach_pane_log = lambda b, pid: None
71+
# Override _write_back to update the file
72+
session._write_back = lambda: _write_session(session_file, session.data)
73+
return session
74+
75+
76+
# All session classes to test
77+
SESSION_CLASSES = [
78+
GeminiProjectSession,
79+
CodexProjectSession,
80+
OpenCodeProjectSession,
81+
DroidProjectSession,
82+
CodebuddyProjectSession,
83+
CopilotProjectSession,
84+
ClaudeProjectSession,
85+
QwenProjectSession,
86+
]
87+
88+
89+
@pytest.mark.parametrize("cls", SESSION_CLASSES, ids=lambda c: c.__name__)
90+
def test_fast_path_returns_correct_pane_when_marker_matches(cls, tmp_path: Path) -> None:
91+
"""When pane_id is alive AND marker resolves to the same pane, return it."""
92+
backend = _FakeBackend(
93+
alive_panes={"%10"},
94+
marker_map={"CCB-Gemini-abc": "%10"},
95+
)
96+
session = _make_session(cls, tmp_path, "%10", "CCB-Gemini-abc", backend)
97+
98+
ok, pane = session.ensure_pane()
99+
100+
assert ok is True
101+
assert pane == "%10"
102+
# pane_id should NOT change
103+
assert session.data["pane_id"] == "%10"
104+
105+
106+
@pytest.mark.parametrize("cls", SESSION_CLASSES, ids=lambda c: c.__name__)
107+
def test_fast_path_switches_to_marker_pane_when_id_stale(cls, tmp_path: Path) -> None:
108+
"""When cached pane_id is alive but marker resolves to a DIFFERENT alive
109+
pane, ensure_pane() should switch to the marker's pane."""
110+
backend = _FakeBackend(
111+
alive_panes={"%10", "%20"},
112+
marker_map={"CCB-Gemini-abc": "%20"},
113+
)
114+
session = _make_session(cls, tmp_path, "%10", "CCB-Gemini-abc", backend)
115+
116+
ok, pane = session.ensure_pane()
117+
118+
assert ok is True
119+
assert pane == "%20"
120+
assert session.data["pane_id"] == "%20"
121+
122+
123+
@pytest.mark.parametrize("cls", SESSION_CLASSES, ids=lambda c: c.__name__)
124+
def test_fast_path_keeps_pane_when_no_marker(cls, tmp_path: Path) -> None:
125+
"""When no title marker is set, fast path should return the alive pane."""
126+
backend = _FakeBackend(alive_panes={"%10"})
127+
session = _make_session(cls, tmp_path, "%10", "", backend)
128+
129+
ok, pane = session.ensure_pane()
130+
131+
assert ok is True
132+
assert pane == "%10"
133+
134+
135+
@pytest.mark.parametrize("cls", SESSION_CLASSES, ids=lambda c: c.__name__)
136+
def test_fallback_resolves_by_marker_when_pane_dead(cls, tmp_path: Path) -> None:
137+
"""When cached pane_id is dead, fall through to marker resolution."""
138+
backend = _FakeBackend(
139+
alive_panes={"%20"},
140+
marker_map={"CCB-Gemini-abc": "%20"},
141+
)
142+
session = _make_session(cls, tmp_path, "%10", "CCB-Gemini-abc", backend)
143+
144+
ok, pane = session.ensure_pane()
145+
146+
assert ok is True
147+
assert pane == "%20"
148+
assert session.data["pane_id"] == "%20"
149+
150+
151+
@pytest.mark.parametrize("cls", SESSION_CLASSES, ids=lambda c: c.__name__)
152+
def test_fast_path_keeps_pane_when_resolver_raises(cls, tmp_path: Path) -> None:
153+
"""If find_pane_by_title_marker raises, fast path should still work."""
154+
class _BrokenBackend(_FakeBackend):
155+
def find_pane_by_title_marker(self, marker: str) -> Optional[str]:
156+
raise RuntimeError("tmux error")
157+
158+
backend = _BrokenBackend(alive_panes={"%10"})
159+
session = _make_session(cls, tmp_path, "%10", "CCB-Gemini-abc", backend)
160+
161+
ok, pane = session.ensure_pane()
162+
163+
assert ok is True
164+
assert pane == "%10"

0 commit comments

Comments
 (0)