3131#: loop's child watcher reaps the pid whether or not we wait.
3232_TMUX_REAP_SECONDS = 0.5
3333
34+ #: Bound on each half of a waiter release: the ``wait-for -S`` that frees
35+ #: the channel, then the waiter's own exit.
36+ _RELEASE_GRACE_SECONDS = 1.0
37+
3438
3539async def _kill_and_reap (
3640 proc : asyncio .subprocess .Process , task : asyncio .Future [t .Any ]
@@ -62,8 +66,63 @@ async def _kill_and_reap(
6266 await asyncio .wait_for (proc .wait (), timeout = _TMUX_REAP_SECONDS )
6367
6468
69+ async def _release_waiter (
70+ proc : asyncio .subprocess .Process ,
71+ task : asyncio .Future [t .Any ],
72+ release_argv : list [str ],
73+ ) -> None :
74+ """End a ``wait-for`` client without leaving a ghost waiter.
75+
76+ tmux keeps a killed waiter queued on its channel and only remembers a
77+ signal while nobody waits, so the channel's next signal would be spent
78+ on the dead client. Signalling while the client is still alive makes
79+ tmux dequeue it, leaving the channel clean. The client is killed only
80+ when it does not exit, which is when the server is not answering.
81+ Mirrors ``libtmux.common._release_waiter``.
82+
83+ Parameters
84+ ----------
85+ proc : asyncio.subprocess.Process
86+ The ``tmux wait-for`` client.
87+ task : asyncio.Future
88+ The in-flight ``proc.communicate()`` future.
89+ release_argv : list of str
90+ Full command line that signals the waiter's channel.
91+ """
92+ with contextlib .suppress (OSError , TimeoutError ):
93+ releaser = await asyncio .create_subprocess_exec (
94+ * release_argv ,
95+ stdout = asyncio .subprocess .DEVNULL ,
96+ stderr = asyncio .subprocess .DEVNULL ,
97+ )
98+ try :
99+ await asyncio .wait_for (releaser .wait (), timeout = _RELEASE_GRACE_SECONDS )
100+ except BaseException :
101+ with contextlib .suppress (ProcessLookupError ):
102+ releaser .kill ()
103+ raise
104+ with contextlib .suppress (TimeoutError ):
105+ await asyncio .wait_for (asyncio .shield (task ), timeout = _RELEASE_GRACE_SECONDS )
106+ await _kill_and_reap (proc , task )
107+
108+
109+ async def _end_child (
110+ proc : asyncio .subprocess .Process ,
111+ task : asyncio .Future [t .Any ],
112+ release_argv : list [str ] | None ,
113+ ) -> None :
114+ """Release a ``wait-for`` waiter, or kill any other child."""
115+ if release_argv is None :
116+ await _kill_and_reap (proc , task )
117+ else :
118+ await _release_waiter (proc , task , release_argv )
119+
120+
65121async def _run_tmux_bounded (
66- argv : list [str ], * , timeout : float
122+ argv : list [str ],
123+ * ,
124+ timeout : float ,
125+ release_argv : list [str ] | None = None ,
67126) -> tuple [int , bytes , bytes ]:
68127 """Run one tmux argv under a hard bound, killing it on cancellation.
69128
@@ -75,6 +134,10 @@ async def _run_tmux_bounded(
75134 timeout : float
76135 Wall-clock bound in seconds. On expiry the child is killed and
77136 reaped before ``TimeoutError`` is raised.
137+ release_argv : list of str, optional
138+ For a ``wait-for`` client: the command that signals its channel.
139+ When given, expiry and cancellation release the waiter through
140+ :func:`_release_waiter` instead of killing it outright.
78141
79142 Returns
80143 -------
@@ -105,7 +168,7 @@ async def _run_tmux_bounded(
105168 try :
106169 done , _pending = await asyncio .wait ({task }, timeout = timeout )
107170 if not done :
108- await _kill_and_reap (proc , task )
171+ await _end_child (proc , task , release_argv )
109172 raise TimeoutError
110173 stdout , stderr = task .result ()
111174 except asyncio .CancelledError :
@@ -115,7 +178,7 @@ async def _run_tmux_bounded(
115178 # above just as often as on ``task.result()``, so the guard
116179 # must span both — otherwise a cancel while waiting orphans the
117180 # child.
118- await _kill_and_reap (proc , task )
181+ await _end_child (proc , task , release_argv )
119182 raise
120183 assert proc .returncode is not None
121184 return proc .returncode , stdout , stderr
0 commit comments