OILS / client / headless_demo.py View on Github | oilshell.org

210 lines, 125 significant
1#!/usr/bin/env python3
2"""
3headless_demo.py
4
5We're using Python 3 because it supports descriptor passing.
6
7Steps:
8
9- Create socketpair() for communication between 2 processes
10- fork() and exec() osh --headless.
11- Communicate synchronously
12"""
13import optparse
14import os
15import pty
16import socket
17import sys
18
19import py_fanos
20from py_fanos import log
21
22
23# EVAL x
24COMMANDS = [
25 b'echo hi', # OK, and prints 'hi' to stdout file descriptor
26 b'echo !!', # no history
27
28 # Headless mode uses something like 'eval' to handle multiline commands
29 b'echo one\necho two\necho three\n', # multiline
30 b'echo 1;\necho 2;\necho 3\n', # with semicolons
31
32 b'( \necho subshell\n)\n', # proper multiline command
33
34 b'ls --color=auto', # OK, and make sure it's in color!
35 b'read x', # OK, and x is assigned
36 b'echo "x: $x"', # OK, we maintained state
37 b'(', # OK, syntax error to stderr
38 b'zzZZ', # OK, and runtime error to stderr
39 b'declare -X', # OK, and runtime error to stderr
40 b'echo PS1=${PS1@P}', # typical prompt command
41 b'echo $? $PWD', # dump state. TODO: JSON?
42
43 # Errors at top level
44 b'break',
45 b'continue',
46
47 # Hm this could actually return
48 b'return',
49 b'exit', # This exists the process
50 b'echo done',
51
52 # What about async commands like &
53 # I think that works the same?
54
55 # Is this valid? EVAL space? I think it probably shouldn't be?
56 b'',
57 # What about invalid netstrings?
58]
59
60
61def ShowDescriptorState(label):
62 if 1:
63 pid = os.getpid()
64 print(label + ' (PID %d)' % pid, file=sys.stderr)
65 os.system('ls -l /proc/%d/fd >&2' % pid)
66
67
68def main(argv):
69 p = optparse.OptionParser(__doc__)
70
71 # By default, the server will use the stdin/stdout/stderr of THIS CLIENT
72 # PROCESS.
73 p.add_option(
74 '--stdin-file', dest='stdin_file', default='/dev/stdin',
75 help='Where the server read stdin from')
76 p.add_option(
77 '--stdout-file', dest='stdout_file', default='/dev/stdout',
78 help='Where the server should send child stdout')
79 p.add_option(
80 '--stderr-file', dest='stderr_file', default='/dev/stderr',
81 help='Where the server should send child stdout')
82 p.add_option(
83 '--sh-binary', dest='sh_binary', default='bin/osh',
84 help='Which shell binary to launch')
85
86 # Use a terminal instead
87 p.add_option(
88 '--to-new-pty', dest='to_new_pty', default=False, action='store_true',
89 help='Send the child stdout to a new PTY')
90
91 opts, args = p.parse_args(argv[1:])
92
93 # left: we read and write from it
94 # right: the server we spawn reads and writes.
95 left, right = socket.socketpair()
96
97 ShowDescriptorState('parent/client BEFORE')
98
99 # The server/child should inherit these descriptors
100 os.set_inheritable(left.fileno(), True)
101 os.set_inheritable(right.fileno(), True)
102
103 child_argv = [opts.sh_binary, '--headless']
104
105 ret = os.fork()
106 if ret < 0:
107 raise AssertionError('fork failed')
108
109 elif ret == 0:
110 left.close() # close parent end in child
111
112 # osh --headless will read from stdin, and write to stdout, which are both
113 # the RIGHT socket.
114 os.dup2(right.fileno(), 0)
115 os.dup2(right.fileno(), 1)
116 right.close() # we don't need this either
117
118 import time
119 time.sleep(0.1) # prevent interleaving of parent/child state
120 ShowDescriptorState('child/server')
121
122 # never returns
123 os.execv(child_argv[0], child_argv)
124 else:
125 right.close() # close child end in parent
126
127 ShowDescriptorState('parent/client AFTER')
128
129 master_fd, slave_fd = -1, -1
130
131 if opts.to_new_pty:
132 master_fd, slave_fd = os.openpty()
133
134 stdin_fd = slave_fd
135 stdout_fd = slave_fd
136 stderr_fd = slave_fd
137
138 log('master %d slave %d', master_fd, slave_fd)
139 #os.close(slave_fd)
140
141 else:
142 stdin_fd = os.open(opts.stdin_file, 0)
143 stdout_fd = os.open(opts.stdout_file, os.O_RDWR | os.O_CREAT)
144 stderr_fd = os.open(opts.stderr_file, os.O_RDWR | os.O_CREAT)
145
146 log('stdout_fd = %d', stdout_fd)
147
148 # Send raw requests, for testing protocol errors
149 if args:
150 raw_requests = [a.encode('utf-8') for a in args]
151
152 status = 0
153 for req in raw_requests:
154 left.send(req)
155
156 try:
157 reply = py_fanos.recv(left)
158 except ValueError as e:
159 log('FANOS protocol error: %s', e)
160 break
161
162 log('reply %r' % reply)
163
164 if reply.startswith(b'ERROR'):
165 status = 1
166
167
168 if reply is None:
169 break
170 return status
171
172 # The normal path
173
174 commands = [b'GETPID']
175 #commands = [b'EVAL echo prompt ${PS1@P}']
176 commands.extend(b'EVAL ' + c for c in COMMANDS)
177
178 for cmd in commands:
179 py_fanos.send(left, cmd, [stdin_fd, stdout_fd, stderr_fd])
180
181 try:
182 reply = py_fanos.recv(left)
183 except ValueError as e:
184 log('FANOS protocol error: %s', e)
185 break
186
187 log('reply %r' % reply)
188 if reply is None:
189 break
190
191 left.close()
192
193 if master_fd != -1:
194 # This hangs because the server still has the terminal open? Not sure
195 # where to close it.
196 while True:
197 chunk = os.read(master_fd, 1024)
198 if not chunk:
199 break
200 log('from pty: %r', chunk)
201
202 return 0
203
204
205if __name__ == '__main__':
206 try:
207 sys.exit(main(sys.argv))
208 except RuntimeError as e:
209 print('FATAL: %s' % e, file=sys.stderr)
210 sys.exit(1)