workmap 0.1.0__py3-none-any.whl

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.
workmap/multiplexer.py ADDED
@@ -0,0 +1,201 @@
1
+ """What a terminal multiplexer is holding, and the only place that runs `tmux`.
2
+
3
+ `terminal.py` is the emulator you are sitting in front of. This is the one
4
+ running inside it. `drivers/` is the only place that speaks AppleScript; this
5
+ is the only place that speaks tmux, and the parallel is deliberate.
6
+
7
+ It is one module rather than a package with a contract, because it only ever
8
+ *reads*. workmap will never retitle a pane, move one or close one, so there is
9
+ nothing here shaped like the driver contract. It becomes a package the day two
10
+ modules would have to answer the same question two different ways. That is not
11
+ a claim about how many multiplexers exist: it is that a multiplexer nobody has
12
+ written code for is protected by construction, because anything that cannot be
13
+ asked ends up in `unasked` and keeps everything under it. A missing
14
+ implementation degrades to the old behaviour rather than to danger, which is
15
+ what makes one module enough.
16
+
17
+ The question is: **which live panes exist, and which process does each hold.**
18
+
19
+ A pane matters because it is somewhere a person can still get back to. That is
20
+ what makes it different from a closed tab, and it is why an unattached pane
21
+ counts: surviving the terminal is the whole point of a multiplexer, and `tmux
22
+ attach` reaches a detached session, which is exactly what an orphan cannot
23
+ offer.
24
+
25
+ Finding the servers and finding their sockets are two problems, and only the
26
+ first is easy. The process table already names every running multiplexer, so
27
+ there is nothing to search for. For the sockets, the obvious answer, listing
28
+ tmux's socket directory, is the wrong one: it misses `tmux -S /some/path`
29
+ entirely, it misses a `$TMUX_TMPDIR` set in somebody else's environment, and
30
+ it finds sockets of servers that have died, which are then asked and answer
31
+ nothing. Ask the running servers instead. One `lsof` over the pids already in
32
+ hand returns each one's socket path, wherever it lives. Measured: a `-S` path
33
+ comes back the same way. It can still name a socket that has been unlinked,
34
+ because a server that unlinks its own socket is still holding it open, and
35
+ that costs one query that fails into the protective answer rather than a whole
36
+ directory of them.
37
+
38
+ The obvious cheaper rule, "a process with a controlling terminal is one
39
+ somebody can get back to", was measured and is false. A child of a pty whose
40
+ master has closed keeps reporting that tty in `ps`, indefinitely: with the
41
+ master open it read ttys010, and after closing the master, and again a second
42
+ later, it still read ttys010. A closed Terminal tab and a closed tmux pane are
43
+ exactly that shape, so a tty proves nothing about whether anybody can reach a
44
+ process, and a tool that believed otherwise would go blind to the very
45
+ orphans it exists to find. tmux naming a pane is evidence. A tty is not.
46
+
47
+ Measured on this machine: `live_panes()` over ~1000 processes takes 1.8ms and
48
+ starts no subprocess when no multiplexer is running, and 28.6ms when one is
49
+ (one `lsof` at ~19ms, one `tmux` query at ~6ms), against a scan of about a
50
+ second.
51
+ """
52
+ from __future__ import annotations
53
+
54
+ from dataclasses import dataclass, field
55
+
56
+ from .config import addressable
57
+ from .model import multiplexer_name
58
+ from .procs import run_partial, unix_socket_paths
59
+
60
+ # Only tmux can say which processes its panes hold. Measured against the
61
+ # screen this machine ships (4.00.03): `screen -ls` names sessions and says
62
+ # attached or detached and stops there; `screen -Q`, which could have
63
+ # answered, arrived in 4.5; and screen 4 talks over FIFOs rather than unix
64
+ # sockets, so there is not even a socket to find. zellij, dtach, abduco and
65
+ # byobu are not measured at all. All five degrade the same way, to being asked
66
+ # nothing, which leaves their whole server holding everything under it. That
67
+ # is the conservative answer, and for screen it is the only one available.
68
+ CAN_BE_ASKED = frozenset({"tmux"})
69
+
70
+ # pane_pid, pane_dead and session_attached are fixed-width fields and go
71
+ # first; the name goes last, because a session name is text somebody chose and
72
+ # may contain spaces, and this is read with a bounded split. Same reason `ps`
73
+ # is asked for `lstart` in front of the command in procs.process_snapshot().
74
+ _PANE_FORMAT = ("#{pane_pid} #{pane_dead} #{session_attached} "
75
+ "#{session_name}:#{window_index}.#{pane_index}")
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Pane:
80
+ pid: int
81
+ where: str # "tmux work:1.0", for the sentence a person reads
82
+ attached: bool
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class Panes:
87
+ """What the multiplexers on this machine are holding.
88
+
89
+ Three answers rather than two, because a server that answered and a server
90
+ that could not be asked hold different amounts:
91
+
92
+ live each pane holds its own subtree
93
+ asked these servers hold only what they are showing
94
+ unasked these servers hold everything under them, as they always did
95
+ """
96
+ live: list = field(default_factory=list)
97
+ asked: list = field(default_factory=list)
98
+ unasked: list = field(default_factory=list)
99
+
100
+
101
+ def live_panes(rows: list[dict]) -> Panes:
102
+ """Every pane a running multiplexer holds, and how much each server keeps.
103
+
104
+ `unasked` is the important part. A multiplexer that could not be
105
+ questioned is not the same as one holding nothing, and it has to be
106
+ treated as holding everything under it. Every way of failing to ask ends
107
+ up there and none of them has to be recognised separately: the multiplexer
108
+ is not tmux, `lsof` could not read the process, `tmux` is not on PATH, the
109
+ server went away between the process table and the query, the socket path
110
+ was too long for the kernel, the query timed out, or the answer did not
111
+ parse. The set is built by starting with every multiplexer and taking away
112
+ only the ones that named at least one live pane, so a way of failing that
113
+ nobody thought of still lands in it.
114
+ """
115
+ mux = [(r["pid"], multiplexer_name(r["cmd"])) for r in rows
116
+ if multiplexer_name(r["cmd"])]
117
+ if not mux:
118
+ # The reason this reads the process table first: on a machine with no
119
+ # multiplexer running, a scan does not gain a single subprocess.
120
+ # Checked by a test that fails if anything is run here.
121
+ return Panes()
122
+
123
+ askable = [pid for pid, name in mux if name in CAN_BE_ASKED]
124
+ unasked = {pid for pid, name in mux if name not in CAN_BE_ASKED}
125
+
126
+ sockets = unix_socket_paths(askable)
127
+ live: list = []
128
+ answered: set = set()
129
+ for path in sorted({p for paths in sockets.values() for p in paths}):
130
+ found = _panes_on(path)
131
+ if found:
132
+ answered.add(path)
133
+ live.extend(found)
134
+
135
+ asked = []
136
+ for pid in askable:
137
+ # A server counts as asked only if one of its own sockets named a live
138
+ # pane. A tmux *client* holds a connection rather than a listening
139
+ # socket, so it usually names nothing and lands in `unasked`, which
140
+ # costs nothing: a client's subtree is the connection and no more, and
141
+ # the terminal it is sitting in already holds it.
142
+ if any(path in answered for path in sockets.get(pid, ())):
143
+ asked.append(pid)
144
+ else:
145
+ unasked.add(pid)
146
+ return Panes(live=live, asked=asked, unasked=sorted(unasked))
147
+
148
+
149
+ def _panes_on(socket_path: str) -> list:
150
+ """The live panes on one socket. Empty means the server could not be asked.
151
+
152
+ A live tmux server always has at least one pane: it exits when its last
153
+ one closes. So an empty answer is never a server holding nothing, it is a
154
+ query that failed, and confusing the two would drop the hold on every
155
+ process in every pane of a live server. `tmux` prints "no server running"
156
+ and "error connecting" to stderr and exits non-zero with nothing on
157
+ stdout, so there is no separate error to read: the emptiness is the
158
+ signal, and reading it that way covers every other way of coming back with
159
+ nothing as well.
160
+ """
161
+ raw = run_partial(
162
+ ["tmux", "-S", socket_path, "list-panes", "-a", "-F", _PANE_FORMAT],
163
+ timeout=3.0,
164
+ )
165
+ return parse_panes(raw)
166
+
167
+
168
+ def parse_panes(raw: str) -> list:
169
+ """tmux's answer, read back.
170
+
171
+ Kept apart from running tmux so it can be tested against output a real
172
+ tmux produced rather than output somebody invented.
173
+ """
174
+ out: list = []
175
+ for line in raw.splitlines():
176
+ parts = line.split(None, 3)
177
+ if len(parts) < 4 or not parts[0].isdigit():
178
+ continue
179
+ # A dead pane is one tmux is still showing after its process exited,
180
+ # which `remain-on-exit` leaves behind. Its pane_pid names a process
181
+ # that is gone, and macOS hands pids back out, so treating it as live
182
+ # would sooner or later hold an unrelated process that inherited the
183
+ # number.
184
+ if parts[1] != "0":
185
+ continue
186
+ out.append(Pane(pid=int(parts[0]),
187
+ where=f"tmux {_plain(parts[3])}",
188
+ attached=parts[2] != "0"))
189
+ return out
190
+
191
+
192
+ def _plain(name: str) -> str:
193
+ """A session name with nothing in it that can move a cursor.
194
+
195
+ This ends up beside a confirmation prompt somebody is about to answer, and
196
+ a session name is text they typed, so it can carry an escape sequence
197
+ exactly as a profile name or a project name can. Cleaned at this boundary
198
+ rather than at each place it is printed, by the same rule that decides
199
+ whether a project name is safe to put on a terminal.
200
+ """
201
+ return name if addressable(name) else "a session"