dsh-wsl-desktop 0.2.0

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.
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env python3
2
+ """PTY bridge for the WSL execution world.
3
+
4
+ `wsl.exe` hands out three pipes, never a terminal, so a real PTY has to be
5
+ allocated on the Linux side and bridged. This script allocates one with the
6
+ `pty` module, runs the requested program on the slave, and pumps bytes between
7
+ the master and the host's pipes:
8
+
9
+ stdin <- host keystrokes, forwarded to the PTY
10
+ stdout -> PTY output, forwarded to the host
11
+ stderr <- one JSON control reply per line, prefixed for the host to demux
12
+
13
+ Control arrives through a FIFO whose path is the first argument, written by a
14
+ second `wsl.exe` process the host keeps open. Only stdlib is used, so nothing
15
+ has to be installed inside the distribution.
16
+
17
+ Usage:
18
+ python3 bridge.py <fifo> <cols> <rows> <argv...>
19
+
20
+ Exits with the child's own exit status (128 + signal when it was killed).
21
+ """
22
+
23
+ import errno
24
+ import fcntl
25
+ import json
26
+ import os
27
+ import pty
28
+ import select
29
+ import signal
30
+ import struct
31
+ import sys
32
+ import termios
33
+ import time
34
+
35
+ REPLY_PREFIX = b"#dsh-pty "
36
+
37
+ SIGNALS = {
38
+ "SIGINT": signal.SIGINT,
39
+ "SIGTERM": signal.SIGTERM,
40
+ "SIGKILL": signal.SIGKILL,
41
+ "SIGHUP": signal.SIGHUP,
42
+ "SIGTSTP": signal.SIGTSTP,
43
+ }
44
+
45
+ GRACE_SECONDS = 3.0
46
+
47
+
48
+ def reply(payload):
49
+ """Write one control reply line for the host to demux off stderr."""
50
+ try:
51
+ os.write(2, REPLY_PREFIX + json.dumps(payload).encode("utf-8") + b"\n")
52
+ except OSError:
53
+ # The host is gone; nothing left to report to.
54
+ pass
55
+
56
+
57
+ def exit_code_of(status):
58
+ """Map a waitpid status onto the shell convention the host reports."""
59
+ if os.WIFEXITED(status):
60
+ return os.WEXITSTATUS(status)
61
+ if os.WIFSIGNALED(status):
62
+ return 128 + os.WTERMSIG(status)
63
+ return 0
64
+
65
+
66
+ class Bridge:
67
+ """One PTY session plus its host-facing pipes."""
68
+
69
+ def __init__(self, fifo, cols, rows, argv):
70
+ self.argv = argv
71
+ self.stdin_fd = 0
72
+ self.stdout_fd = 1
73
+ self.master = None
74
+ self.pid = None
75
+ self.shell_pgrp = None
76
+ self.stdin_open = True
77
+ self.control_buffer = b""
78
+ self.revision = 0
79
+ self.last_state = None
80
+ self.control_fd = self._open_control(fifo)
81
+ self._spawn()
82
+ self.resize(cols, rows)
83
+
84
+ def _open_control(self, fifo):
85
+ """Create the control FIFO and hold its read end open."""
86
+ try:
87
+ os.unlink(fifo)
88
+ except FileNotFoundError:
89
+ pass
90
+ os.mkfifo(fifo, 0o600)
91
+ return os.open(fifo, os.O_RDONLY | os.O_NONBLOCK)
92
+
93
+ def _spawn(self):
94
+ """Fork the program onto a new PTY."""
95
+ self.pid, self.master = pty.fork()
96
+ if self.pid == 0:
97
+ try:
98
+ os.execvp(self.argv[0], self.argv)
99
+ except OSError:
100
+ pass
101
+ os._exit(127)
102
+ self.shell_pgrp = os.getpgid(self.pid)
103
+ reply({"event": "started", "pid": self.pid, "pgrp": self.shell_pgrp})
104
+
105
+ def resize(self, cols, rows):
106
+ """Apply a window size to the PTY."""
107
+ fcntl.ioctl(self.master, termios.TIOCSWINSZ, struct.pack("HHHH", int(rows), int(cols), 0, 0))
108
+
109
+ def foreground_pgrp(self):
110
+ """The PTY's current foreground process group."""
111
+ try:
112
+ return os.tcgetpgrp(self.master)
113
+ except OSError:
114
+ return None
115
+
116
+ def activity(self):
117
+ """Report busy when the foreground group is not the shell itself."""
118
+ pgrp = self.foreground_pgrp()
119
+ state = "unknown" if pgrp is None else ("idle" if pgrp == self.shell_pgrp else "busy")
120
+ if state != self.last_state:
121
+ self.last_state = state
122
+ self.revision += 1
123
+ return {"state": state, "revision": self.revision}
124
+
125
+ def drain(self):
126
+ """Read every immediately available PTY byte, stopping at the EIO that marks a closed slave."""
127
+ while True:
128
+ try:
129
+ ready, _, _ = select.select([self.master], [], [], 0)
130
+ except InterruptedError:
131
+ return
132
+ if not ready:
133
+ return
134
+ try:
135
+ data = os.read(self.master, 65536)
136
+ except OSError as error:
137
+ # EIO is how Linux reports that the slave side is gone.
138
+ if error.errno == errno.EIO:
139
+ return
140
+ raise
141
+ if not data:
142
+ return
143
+ os.write(self.stdout_fd, data)
144
+
145
+ def handle_control(self, line):
146
+ """Run one control request and answer it."""
147
+ try:
148
+ request = json.loads(line.decode("utf-8"))
149
+ except (ValueError, UnicodeDecodeError):
150
+ reply({"ok": False, "error": "control line is not JSON"})
151
+ return
152
+ request_id = request.get("id")
153
+
154
+ def ans(payload):
155
+ """Answer the request, echoing its id so the host can pair the reply."""
156
+ if request_id is not None:
157
+ payload = {"id": request_id, **payload}
158
+ reply(payload)
159
+
160
+ op = request.get("op")
161
+ if op == "resize":
162
+ self.resize(request.get("cols", 80), request.get("rows", 24))
163
+ ans({"ok": True})
164
+ elif op == "foreground":
165
+ pgrp = self.foreground_pgrp()
166
+ ans({"ok": True, "pgrp": pgrp, "shellPgrp": self.shell_pgrp})
167
+ elif op == "activity":
168
+ ans({"ok": True, **self.activity()})
169
+ elif op == "signal":
170
+ name = request.get("signal")
171
+ number = SIGNALS.get(name)
172
+ if number is None:
173
+ ans({"ok": False, "error": "unsupported signal %s" % name})
174
+ return
175
+ pgrp = self.foreground_pgrp()
176
+ if pgrp is None:
177
+ ans({"ok": False, "error": "no foreground process group"})
178
+ return
179
+ try:
180
+ os.killpg(pgrp, number)
181
+ ans({"ok": True, "pgrp": pgrp})
182
+ except OSError as error:
183
+ ans({"ok": False, "error": str(error)})
184
+ elif op == "terminate":
185
+ ans({"ok": True, "terminated": self.terminate()})
186
+ else:
187
+ ans({"ok": False, "error": "unknown op"})
188
+
189
+ def terminate(self):
190
+ """Signal the whole session and report the group that received it."""
191
+ pgrp = self.foreground_pgrp() or self.shell_pgrp
192
+ for number, wait in ((signal.SIGHUP, 0.2), (signal.SIGTERM, GRACE_SECONDS), (signal.SIGKILL, 0.2)):
193
+ try:
194
+ os.killpg(pgrp, number)
195
+ except OSError:
196
+ break
197
+ deadline = time.monotonic() + wait
198
+ while time.monotonic() < deadline:
199
+ done, _ = os.waitpid(self.pid, os.WNOHANG)
200
+ if done == self.pid:
201
+ return True
202
+ time.sleep(0.05)
203
+ return False
204
+
205
+ def run(self):
206
+ """Pump bytes until the child settles, then exit with its status."""
207
+ status = 0
208
+ while True:
209
+ watched = [self.master, self.control_fd]
210
+ if self.stdin_open:
211
+ watched.append(self.stdin_fd)
212
+ try:
213
+ ready, _, _ = select.select(watched, [], [], 0.2)
214
+ except InterruptedError:
215
+ continue
216
+
217
+ if self.master in ready:
218
+ try:
219
+ data = os.read(self.master, 65536)
220
+ except OSError as error:
221
+ data = b""
222
+ if error.errno != errno.EIO:
223
+ raise
224
+ if data:
225
+ os.write(self.stdout_fd, data)
226
+
227
+ if self.stdin_open and self.stdin_fd in ready:
228
+ try:
229
+ data = os.read(self.stdin_fd, 65536)
230
+ except OSError:
231
+ data = b""
232
+ if data:
233
+ try:
234
+ os.write(self.master, data)
235
+ except OSError:
236
+ # The session ended between the select and the write.
237
+ pass
238
+ else:
239
+ # The host closed its side; keep the session but stop polling it.
240
+ self.stdin_open = False
241
+
242
+ if self.control_fd in ready:
243
+ chunk = os.read(self.control_fd, 65536)
244
+ self.control_buffer += chunk
245
+ while b"\n" in self.control_buffer:
246
+ line, self.control_buffer = self.control_buffer.split(b"\n", 1)
247
+ if line.strip():
248
+ self.handle_control(line)
249
+
250
+ try:
251
+ done, status = os.waitpid(self.pid, os.WNOHANG)
252
+ except ChildProcessError:
253
+ done, status = self.pid, 0
254
+ if done == self.pid:
255
+ self.drain()
256
+ return exit_code_of(status)
257
+
258
+
259
+ def main():
260
+ if len(sys.argv) < 5:
261
+ sys.stderr.write("usage: bridge.py <fifo> <cols> <rows> <argv...>\n")
262
+ return 2
263
+ fifo = sys.argv[1]
264
+ cols, rows = sys.argv[2], sys.argv[3]
265
+ argv = sys.argv[4:]
266
+ bridge = Bridge(fifo, cols, rows, argv)
267
+ try:
268
+ code = bridge.run()
269
+ finally:
270
+ try:
271
+ os.unlink(fifo)
272
+ except OSError:
273
+ pass
274
+ return code
275
+
276
+
277
+ if __name__ == "__main__":
278
+ sys.exit(main())