Skip to content

Commit ebc825d

Browse files
committed
ControlProtocol(fix): Add SKIPPING state for unexpected %begin blocks
why: Hook commands trigger additional %begin/%end blocks that desync the command queue, causing protocol errors and timeouts. what: - Add ParserState.SKIPPING to handle unexpected %begin blocks - Skip block content instead of marking connection DEAD - Return to IDLE when skipped block ends with %end/%error - Update protocol test to verify new SKIPPING behavior
1 parent 50d2ab4 commit ebc825d

File tree

2 files changed

+40
-6
lines changed

2 files changed

+40
-6
lines changed

src/libtmux/_internal/engines/control_protocol.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ class ParserState(enum.Enum):
6161

6262
IDLE = enum.auto()
6363
IN_COMMAND = enum.auto()
64+
SKIPPING = enum.auto() # Skipping unexpected %begin/%end block
6465
DEAD = enum.auto()
6566

6667

@@ -214,10 +215,17 @@ def _handle_percent_line(self, line: str) -> None:
214215
def _handle_plain_line(self, line: str) -> None:
215216
if self.state is ParserState.IN_COMMAND and self._current:
216217
self._current.stdout.append(line)
218+
elif self.state is ParserState.SKIPPING:
219+
# Ignore output from skipped blocks (hook command output)
220+
pass
217221
else:
218222
logger.debug("Unexpected plain line outside command: %r", line)
219223

220224
def _on_begin(self, parts: list[str]) -> None:
225+
if self.state is ParserState.SKIPPING:
226+
# Nested %begin while skipping - ignore
227+
logger.debug("Nested %%begin while skipping: %s", parts)
228+
return
221229
if self.state is not ParserState.IDLE:
222230
self._protocol_error("nested %begin")
223231
return
@@ -233,7 +241,12 @@ def _on_begin(self, parts: list[str]) -> None:
233241
try:
234242
ctx = self._pending.popleft()
235243
except IndexError:
236-
self._protocol_error(f"no pending command for %begin id={cmd_id}")
244+
# No pending command - this is likely from a hook action.
245+
# Skip this block instead of killing the connection.
246+
logger.debug(
247+
"Unexpected %%begin id=%d (hook execution?), skipping block", cmd_id
248+
)
249+
self.state = ParserState.SKIPPING
237250
return
238251

239252
ctx.cmd_id = cmd_id
@@ -244,6 +257,11 @@ def _on_begin(self, parts: list[str]) -> None:
244257
self.state = ParserState.IN_COMMAND
245258

246259
def _on_end_or_error(self, tag: str, parts: list[str]) -> None:
260+
if self.state is ParserState.SKIPPING:
261+
# End of skipped block - return to idle
262+
logger.debug("Skipped block ended with %s", tag)
263+
self.state = ParserState.IDLE
264+
return
247265
if self.state is not ParserState.IN_COMMAND or self._current is None:
248266
self._protocol_error(f"unexpected {tag}")
249267
return

tests/test_engine_protocol.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,6 @@ def test_control_protocol_notifications() -> None:
9292
line="%end 123 1 0",
9393
expected_reason="unexpected %end",
9494
),
95-
ProtocolErrorFixture(
96-
test_id="no_pending_begin",
97-
line="%begin 999 1 0",
98-
expected_reason="no pending command for %begin",
99-
),
10095
]
10196

10297

@@ -111,6 +106,27 @@ def test_control_protocol_errors(case: ProtocolErrorFixture) -> None:
111106
assert case.expected_reason in stats.last_error
112107

113108

109+
def test_control_protocol_skips_unexpected_begin() -> None:
110+
"""Unexpected %begin (e.g., from hook execution) should enter SKIPPING state.
111+
112+
This is not a fatal error - hooks can trigger additional %begin/%end blocks
113+
that have no matching registered command. The protocol skips these blocks
114+
instead of marking the connection dead.
115+
"""
116+
proto = ControlProtocol()
117+
proto.feed_line("%begin 999 1 0")
118+
assert proto.state is ParserState.SKIPPING
119+
# Output during skipped block is ignored
120+
proto.feed_line("some hook output")
121+
assert proto.state is ParserState.SKIPPING
122+
# End of skipped block returns to IDLE
123+
proto.feed_line("%end 999 1 0")
124+
assert proto.state is ParserState.IDLE
125+
# Connection is still usable
126+
stats = proto.get_stats(restarts=0)
127+
assert stats.last_error is None
128+
129+
114130
NOTIFICATION_FIXTURES: list[NotificationFixture] = [
115131
NotificationFixture(
116132
test_id="layout_change",

0 commit comments

Comments
 (0)