OILS / core / main_loop.py View on Github | oilshell.org

403 lines, 226 significant
1"""main_loop.py.
2
3Variants:
4 main_loop.Interactive() calls ParseInteractiveLine() and ExecuteAndCatch()
5 main_loop.Batch() calls ParseLogicalLine() and ExecuteAndCatch()
6 main_loop.Headless() calls Batch() like eval and source.
7 We want 'echo 1\necho 2\n' to work, so we
8 don't bother with "the PS2 problem".
9 main_loop.ParseWholeFile() calls ParseLogicalLine(). Used by osh -n.
10"""
11from __future__ import print_function
12
13from _devbuild.gen import arg_types
14from _devbuild.gen.syntax_asdl import (command, command_t, parse_result,
15 parse_result_e)
16from core import error
17from core import process
18from display import ui
19from core import util
20from frontend import reader
21from osh import cmd_eval
22from mycpp import mylib
23from mycpp.mylib import log, print_stderr, probe, tagswitch
24
25import fanos
26import posix_ as posix
27
28from typing import cast, Any, List, TYPE_CHECKING
29if TYPE_CHECKING:
30 from core.comp_ui import _IDisplay
31 from frontend import parse_lib
32 from osh.cmd_parse import CommandParser
33 from osh.cmd_eval import CommandEvaluator
34 from osh.prompt import UserPlugin
35
36_ = log
37
38
39class ctx_Descriptors(object):
40 """Save and restore descriptor state for the headless EVAL command."""
41
42 def __init__(self, fds):
43 # type: (List[int]) -> None
44
45 self.saved0 = process.SaveFd(0)
46 self.saved1 = process.SaveFd(1)
47 self.saved2 = process.SaveFd(2)
48
49 #ShowDescriptorState('BEFORE')
50 posix.dup2(fds[0], 0)
51 posix.dup2(fds[1], 1)
52 posix.dup2(fds[2], 2)
53
54 self.fds = fds
55
56 def __enter__(self):
57 # type: () -> None
58 pass
59
60 def __exit__(self, type, value, traceback):
61 # type: (Any, Any, Any) -> None
62
63 # Restore
64 posix.dup2(self.saved0, 0)
65 posix.dup2(self.saved1, 1)
66 posix.dup2(self.saved2, 2)
67
68 # Restoration done, so close
69 posix.close(self.saved0)
70 posix.close(self.saved1)
71 posix.close(self.saved2)
72
73 # And close descriptors we were passed
74 posix.close(self.fds[0])
75 posix.close(self.fds[1])
76 posix.close(self.fds[2])
77
78
79def fanos_log(msg):
80 # type: (str) -> None
81 print_stderr('[FANOS] %s' % msg)
82
83
84def ShowDescriptorState(label):
85 # type: (str) -> None
86 if mylib.PYTHON:
87 import os # Our posix fork doesn't have os.system
88 import time
89 time.sleep(0.01) # prevent interleaving
90
91 pid = posix.getpid()
92 print_stderr(label + ' (PID %d)' % pid)
93
94 os.system('ls -l /proc/%d/fd >&2' % pid)
95
96 time.sleep(0.01) # prevent interleaving
97
98
99class Headless(object):
100 """Main loop for headless mode."""
101
102 def __init__(self, cmd_ev, parse_ctx, errfmt):
103 # type: (CommandEvaluator, parse_lib.ParseContext, ui.ErrorFormatter) -> None
104 self.cmd_ev = cmd_ev
105 self.parse_ctx = parse_ctx
106 self.errfmt = errfmt
107
108 def Loop(self):
109 # type: () -> int
110 try:
111 return self._Loop()
112 except ValueError as e:
113 fanos.send(1, 'ERROR %s' % e)
114 return 1
115
116 def EVAL(self, arg):
117 # type: (str) -> str
118
119 # This logic is similar to the 'eval' builtin in osh/builtin_meta.
120
121 # Note: we're not using the InteractiveLineReader, so there's no history
122 # expansion. It would be nice if there was a way for the client to use
123 # that.
124 line_reader = reader.StringLineReader(arg, self.parse_ctx.arena)
125 c_parser = self.parse_ctx.MakeOshParser(line_reader)
126
127 # Status is unused; $_ can be queried by the headless client
128 unused_status = Batch(self.cmd_ev, c_parser, self.errfmt, 0)
129
130 return '' # result is always 'OK ' since there was no protocol error
131
132 def _Loop(self):
133 # type: () -> int
134 fanos_log(
135 'Connect stdin and stdout to one end of socketpair() and send control messages. osh writes debug messages (like this one) to stderr.'
136 )
137
138 fd_out = [] # type: List[int]
139 while True:
140 try:
141 blob = fanos.recv(0, fd_out)
142 except ValueError as e:
143 fanos_log('protocol error: %s' % e)
144 raise # higher level handles it
145
146 if blob is None:
147 fanos_log('EOF received')
148 break
149
150 fanos_log('received blob %r' % blob)
151 if ' ' in blob:
152 bs = blob.split(' ', 1)
153 command = bs[0]
154 arg = bs[1]
155 else:
156 command = blob
157 arg = ''
158
159 if command == 'GETPID':
160 reply = str(posix.getpid())
161
162 elif command == 'EVAL':
163 #fanos_log('arg %r', arg)
164
165 if len(fd_out) != 3:
166 raise ValueError('Expected 3 file descriptors')
167
168 for fd in fd_out:
169 fanos_log('received descriptor %d' % fd)
170
171 with ctx_Descriptors(fd_out):
172 reply = self.EVAL(arg)
173
174 #ShowDescriptorState('RESTORED')
175
176 # Note: lang == 'osh' or lang == 'ysh' puts this in different modes.
177 # Do we also need 'complete --osh' and 'complete --ysh' ?
178 elif command == 'PARSE':
179 # Just parse
180 reply = 'TODO:PARSE'
181
182 else:
183 fanos_log('Invalid command %r' % command)
184 raise ValueError('Invalid command %r' % command)
185
186 fanos.send(1, b'OK %s' % reply)
187 del fd_out[:] # reset for next iteration
188
189 return 0
190
191
192def Interactive(
193 flag, # type: arg_types.main
194 cmd_ev, # type: CommandEvaluator
195 c_parser, # type: CommandParser
196 display, # type: _IDisplay
197 prompt_plugin, # type: UserPlugin
198 waiter, # type: process.Waiter
199 errfmt, # type: ui.ErrorFormatter
200):
201 # type: (...) -> int
202 status = 0
203 done = False
204 while not done:
205 mylib.MaybeCollect() # manual GC point
206
207 # - This loop has a an odd structure because we want to do cleanup
208 # after every 'break'. (The ones without 'done = True' were
209 # 'continue')
210 # - display.EraseLines() needs to be called BEFORE displaying anything, so
211 # it appears in all branches.
212
213 while True: # ONLY EXECUTES ONCE
214 quit = False
215 prompt_plugin.Run()
216 try:
217 # may raise HistoryError or ParseError
218 result = c_parser.ParseInteractiveLine()
219 UP_result = result
220 with tagswitch(result) as case:
221 if case(parse_result_e.EmptyLine):
222 display.EraseLines()
223 # POSIX shell behavior: waitpid(-1) and show job "Done"
224 # messages
225 waiter.PollNotifications()
226 quit = True
227 elif case(parse_result_e.Eof):
228 display.EraseLines()
229 done = True
230 quit = True
231 elif case(parse_result_e.Node):
232 result = cast(parse_result.Node, UP_result)
233 node = result.cmd
234 else:
235 raise AssertionError()
236
237 except util.HistoryError as e: # e.g. expansion failed
238 # Where this happens:
239 # for i in 1 2 3; do
240 # !invalid
241 # done
242 display.EraseLines()
243 print(e.UserErrorString())
244 quit = True
245 except error.Parse as e:
246 display.EraseLines()
247 errfmt.PrettyPrintError(e)
248 status = 2
249 cmd_ev.mem.SetLastStatus(status)
250 quit = True
251 except KeyboardInterrupt: # thrown by InteractiveLineReader._GetLine()
252 # Here we must print a newline BEFORE EraseLines()
253 print('^C')
254 display.EraseLines()
255 # http://www.tldp.org/LDP/abs/html/exitcodes.html
256 # bash gives 130, dash gives 0, zsh gives 1.
257 # Unless we SET cmd_ev.last_status, scripts see it, so don't bother now.
258 quit = True
259
260 if quit:
261 break
262
263 display.EraseLines() # Clear candidates right before executing
264
265 # to debug the slightly different interactive prasing
266 if cmd_ev.exec_opts.noexec():
267 ui.PrintAst(node, flag)
268 break
269
270 try:
271 is_return, _ = cmd_ev.ExecuteAndCatch(node, 0)
272 except KeyboardInterrupt: # issue 467, Ctrl-C during $(sleep 1)
273 is_return = False
274 display.EraseLines()
275 status = 130 # 128 + 2
276 cmd_ev.mem.SetLastStatus(status)
277 break
278
279 status = cmd_ev.LastStatus()
280
281 waiter.PollNotifications()
282
283 if is_return:
284 done = True
285 break
286
287 break # QUIT LOOP after one iteration.
288
289 # After every "logical line", no lines will be referenced by the Arena.
290 # Tokens in the LST still point to many lines, but lines with only comment
291 # or whitespace won't be reachable, so the GC will free them.
292 c_parser.arena.DiscardLines()
293
294 cmd_ev.RunPendingTraps() # Run trap handlers even if we get just ENTER
295
296 # Cleanup after every command (or failed command).
297
298 # Reset internal newline state.
299 c_parser.Reset()
300 c_parser.ResetInputObjects()
301
302 display.Reset() # clears dupes and number of lines last displayed
303
304 # TODO: Replace this with a shell hook? with 'trap', or it could be just
305 # like command_not_found. The hook can be 'echo $?' or something more
306 # complicated, i.e. with timestamps.
307 if flag.print_status:
308 print('STATUS\t%r' % status)
309
310 return status
311
312
313def Batch(cmd_ev, c_parser, errfmt, cmd_flags=0):
314 # type: (CommandEvaluator, CommandParser, ui.ErrorFormatter, int) -> int
315 """Loop for batch execution.
316
317 Returns:
318 int status, e.g. 2 on parse error
319
320 Can this be combined with interactive loop? Differences:
321
322 - Handling of parse errors.
323 - Have to detect here docs at the end?
324
325 Not a problem:
326 - Get rid of --print-status and --show-ast for now
327 - Get rid of EOF difference
328
329 TODO:
330 - Do source / eval need this?
331 - 'source' needs to parse incrementally so that aliases are respected
332 - I doubt 'eval' does! You can test it.
333 - In contrast, 'trap' should parse up front?
334 - What about $() ?
335 """
336 status = 0
337 while True:
338 probe('main_loop', 'Batch_parse_enter')
339 try:
340 node = c_parser.ParseLogicalLine() # can raise ParseError
341 if node is None: # EOF
342 c_parser.CheckForPendingHereDocs() # can raise ParseError
343 break
344 except error.Parse as e:
345 errfmt.PrettyPrintError(e)
346 status = 2
347 break
348
349 # After every "logical line", no lines will be referenced by the Arena.
350 # Tokens in the LST still point to many lines, but lines with only comment
351 # or whitespace won't be reachable, so the GC will free them.
352 c_parser.arena.DiscardLines()
353
354 # Only optimize if we're on the last line like -c "echo hi" etc.
355 if (cmd_flags & cmd_eval.IsMainProgram and
356 c_parser.line_reader.LastLineHint()):
357 cmd_flags |= cmd_eval.OptimizeSubshells
358 if not cmd_ev.exec_opts.verbose_errexit():
359 cmd_flags |= cmd_eval.MarkLastCommands
360
361 probe('main_loop', 'Batch_parse_exit')
362
363 probe('main_loop', 'Batch_execute_enter')
364 # can't optimize this because we haven't seen the end yet
365 is_return, is_fatal = cmd_ev.ExecuteAndCatch(node, cmd_flags)
366 status = cmd_ev.LastStatus()
367 # e.g. 'return' in middle of script, or divide by zero
368 if is_return or is_fatal:
369 break
370 probe('main_loop', 'Batch_execute_exit')
371
372 probe('main_loop', 'Batch_collect_enter')
373 mylib.MaybeCollect() # manual GC point
374 probe('main_loop', 'Batch_collect_exit')
375
376 return status
377
378
379def ParseWholeFile(c_parser):
380 # type: (CommandParser) -> command_t
381 """Parse an entire shell script.
382
383 This uses the same logic as Batch(). Used by:
384 - osh -n
385 - oshc translate
386 - Used by 'trap' to store code. But 'source' and 'eval' use Batch().
387
388 Note: it does NOT call DiscardLines
389 """
390 children = [] # type: List[command_t]
391 while True:
392 node = c_parser.ParseLogicalLine() # can raise ParseError
393 if node is None: # EOF
394 c_parser.CheckForPendingHereDocs() # can raise ParseError
395 break
396 children.append(node)
397
398 mylib.MaybeCollect() # manual GC point
399
400 if len(children) == 1:
401 return children[0]
402 else:
403 return command.CommandList(children)