OILS / core / main_loop.py View on Github | oils.pub

512 lines, 286 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, source)
16from core import alloc
17from core import error
18from core import process
19from core import pyos
20from core import state
21from core import util
22from display import ui
23from frontend import reader
24from osh import cmd_eval
25from mycpp import mylib
26from mycpp.mylib import log, print_stderr, probe, tagswitch
27
28import fanos
29import posix_ as posix
30
31from typing import cast, Any, List, Tuple, TYPE_CHECKING
32if TYPE_CHECKING:
33 from core.comp_ui import _IDisplay
34 from core import process
35 from frontend import parse_lib
36 from osh import cmd_parse
37 from osh import cmd_eval
38 from osh.prompt import UserPlugin
39
40_ = log
41
42
43class ctx_Descriptors(object):
44 """Save and restore descriptor state for the headless EVAL command."""
45
46 def __init__(self, fds):
47 # type: (List[int]) -> None
48
49 self.saved0 = process.SaveFd(0)
50 self.saved1 = process.SaveFd(1)
51 self.saved2 = process.SaveFd(2)
52
53 #ShowDescriptorState('BEFORE')
54 posix.dup2(fds[0], 0)
55 posix.dup2(fds[1], 1)
56 posix.dup2(fds[2], 2)
57
58 self.fds = fds
59
60 def __enter__(self):
61 # type: () -> None
62 pass
63
64 def __exit__(self, type, value, traceback):
65 # type: (Any, Any, Any) -> None
66
67 # Restore
68 posix.dup2(self.saved0, 0)
69 posix.dup2(self.saved1, 1)
70 posix.dup2(self.saved2, 2)
71
72 # Restoration done, so close
73 posix.close(self.saved0)
74 posix.close(self.saved1)
75 posix.close(self.saved2)
76
77 # And close descriptors we were passed
78 posix.close(self.fds[0])
79 posix.close(self.fds[1])
80 posix.close(self.fds[2])
81
82
83def fanos_log(msg):
84 # type: (str) -> None
85 print_stderr('[FANOS] %s' % msg)
86
87
88def ShowDescriptorState(label):
89 # type: (str) -> None
90 if mylib.PYTHON:
91 import os # Our posix fork doesn't have os.system
92 import time
93 time.sleep(0.01) # prevent interleaving
94
95 pid = posix.getpid()
96 print_stderr(label + ' (PID %d)' % pid)
97
98 os.system('ls -l /proc/%d/fd >&2' % pid)
99
100 time.sleep(0.01) # prevent interleaving
101
102
103class Headless(object):
104 """Main loop for headless mode."""
105
106 def __init__(self, cmd_ev, parse_ctx, errfmt):
107 # type: (cmd_eval.CommandEvaluator, parse_lib.ParseContext, ui.ErrorFormatter) -> None
108 self.cmd_ev = cmd_ev
109 self.parse_ctx = parse_ctx
110 self.errfmt = errfmt
111
112 def Loop(self):
113 # type: () -> int
114 try:
115 return self._Loop()
116 except ValueError as e:
117 fanos.send(1, 'ERROR %s' % e)
118 return 1
119
120 def EVAL(self, arg):
121 # type: (str) -> str
122
123 # This logic is similar to the 'eval' builtin in osh/builtin_meta.
124
125 # Note: we're not using the InteractiveLineReader, so there's no history
126 # expansion. It would be nice if there was a way for the client to use
127 # that.
128 line_reader = reader.StringLineReader(arg, self.parse_ctx.arena)
129 c_parser = self.parse_ctx.MakeOshParser(line_reader)
130
131 # Status is unused; $_ can be queried by the headless client
132 unused_status = Batch(self.cmd_ev, c_parser, self.errfmt, 0)
133
134 return '' # result is always 'OK ' since there was no protocol error
135
136 def _Loop(self):
137 # type: () -> int
138 fanos_log(
139 'Connect stdin and stdout to one end of socketpair() and send control messages. osh writes debug messages (like this one) to stderr.'
140 )
141
142 fd_out = [] # type: List[int]
143 while True:
144 try:
145 blob = fanos.recv(0, fd_out)
146 except ValueError as e:
147 fanos_log('protocol error: %s' % e)
148 raise # higher level handles it
149
150 if blob is None:
151 fanos_log('EOF received')
152 break
153
154 fanos_log('received blob %r' % blob)
155 if ' ' in blob:
156 bs = blob.split(' ', 1)
157 command = bs[0]
158 arg = bs[1]
159 else:
160 command = blob
161 arg = ''
162
163 if command == 'GETPID':
164 reply = str(posix.getpid())
165
166 elif command == 'EVAL':
167 #fanos_log('arg %r', arg)
168
169 if len(fd_out) != 3:
170 raise ValueError('Expected 3 file descriptors')
171
172 for fd in fd_out:
173 fanos_log('received descriptor %d' % fd)
174
175 with ctx_Descriptors(fd_out):
176 reply = self.EVAL(arg)
177
178 #ShowDescriptorState('RESTORED')
179
180 # Note: lang == 'osh' or lang == 'ysh' puts this in different modes.
181 # Do we also need 'complete --osh' and 'complete --ysh' ?
182 elif command == 'PARSE':
183 # Just parse
184 reply = 'TODO:PARSE'
185
186 else:
187 fanos_log('Invalid command %r' % command)
188 raise ValueError('Invalid command %r' % command)
189
190 fanos.send(1, b'OK %s' % reply)
191 del fd_out[:] # reset for next iteration
192
193 return 0
194
195
196def _UpdateTerminalSize(mem, readline):
197 # type: (state.Mem, Any) -> None
198 """Query the terminal for its current size and update COLUMNS, LINES,
199 and readline's internal screen width.
200
201 Analogous to bash's sh_set_lines_and_columns().
202 """
203 rows, cols = pyos.GetTerminalSize()
204 if cols > 0:
205 state.SetGlobalString(mem, 'COLUMNS', str(cols))
206 if rows > 0:
207 state.SetGlobalString(mem, 'LINES', str(rows))
208 # Also update readline's internal idea of the terminal size so that
209 # line editing / wrapping works correctly.
210 if readline is not None:
211 readline.resize_terminal()
212
213
214def Interactive(
215 flag, # type: arg_types.main
216 cmd_ev, # type: cmd_eval.CommandEvaluator
217 c_parser, # type: cmd_parse.CommandParser
218 display, # type: _IDisplay
219 prompt_plugin, # type: UserPlugin
220 waiter, # type: process.Waiter
221 errfmt, # type: ui.ErrorFormatter
222 readline=None, # type: Any
223):
224 # type: (...) -> int
225 status = 0
226 done = False
227 while not done:
228 mylib.MaybeCollect() # manual GC point
229
230 # Update COLUMNS and LINES from the terminal every iteration.
231 # This keeps $COLUMNS/$LINES in sync after SIGWINCH, analogous to
232 # bash's sh_set_lines_and_columns() and checkwinsize.
233 _UpdateTerminalSize(cmd_ev.mem, readline)
234
235 # - This loop has a an odd structure because we want to do cleanup
236 # after every 'break'. (The ones without 'done = True' were
237 # 'continue')
238 # - display.EraseLines() needs to be called BEFORE displaying anything, so
239 # it appears in all branches.
240
241 while True: # ONLY EXECUTES ONCE
242 quit = False
243 prompt_plugin.Run()
244 try:
245 # may raise HistoryError or ParseError
246 result = c_parser.ParseInteractiveLine()
247 UP_result = result
248 with tagswitch(result) as case:
249 if case(parse_result_e.EmptyLine):
250 display.EraseLines()
251 # POSIX shell behavior: waitpid(-1) and show job "Done"
252 # messages
253 waiter.PollForEvents()
254 quit = True
255 elif case(parse_result_e.Eof):
256 display.EraseLines()
257 done = True
258 quit = True
259 elif case(parse_result_e.Node):
260 result = cast(parse_result.Node, UP_result)
261 node = result.cmd
262 else:
263 raise AssertionError()
264
265 except util.HistoryError as e: # e.g. expansion failed
266 # Where this happens:
267 # for i in 1 2 3; do
268 # !invalid
269 # done
270 display.EraseLines()
271 print(e.UserErrorString())
272 quit = True
273 except error.Parse as e:
274 display.EraseLines()
275 errfmt.PrettyPrintError(e)
276 status = 2
277 cmd_ev.mem.SetLastStatus(status)
278 quit = True
279 except KeyboardInterrupt: # thrown by InteractiveLineReader._GetLine()
280 # TODO: We probably want to change terminal settings so ^C is printed.
281 # For now, just print a newline.
282 #
283 # WITHOUT GNU readline, the ^C is printed. So we need to make
284 # the 2 cases consistent.
285 print('')
286
287 if 0:
288 from core import pyos
289 pyos.FlushStdout()
290
291 display.EraseLines()
292 quit = True
293
294 if quit:
295 break
296
297 display.EraseLines() # Clear candidates right before executing
298
299 # to debug the slightly different interactive prasing
300 if cmd_ev.exec_opts.noexec():
301 ui.PrintAst(node, flag)
302 break
303
304 try:
305 is_return, _ = cmd_ev.ExecuteAndCatch(node, 0)
306 except KeyboardInterrupt: # issue 467, Ctrl-C during $(sleep 1)
307 is_return = False
308 display.EraseLines()
309
310 # http://www.tldp.org/LDP/abs/html/exitcodes.html
311 # bash gives 130, dash gives 0, zsh gives 1.
312 status = 130 # 128 + 2
313
314 cmd_ev.mem.SetLastStatus(status)
315 break
316
317 status = cmd_ev.LastStatus()
318
319 waiter.PollForEvents()
320
321 if is_return:
322 done = True
323 break
324
325 break # QUIT LOOP after one iteration.
326
327 # After every "logical line", no lines will be referenced by the Arena.
328 # Tokens in the LST still point to many lines, but lines with only comment
329 # or whitespace won't be reachable, so the GC will free them.
330 c_parser.arena.DiscardLines()
331
332 cmd_ev.RunPendingTraps() # Run trap handlers even if we get just ENTER
333
334 # Cleanup after every command (or failed command).
335
336 # Reset internal newline state.
337 c_parser.Reset()
338 c_parser.ResetInputObjects()
339
340 display.Reset() # clears dupes and number of lines last displayed
341
342 # TODO: Replace this with a shell hook? with 'trap', or it could be just
343 # like command_not_found. The hook can be 'echo $?' or something more
344 # complicated, i.e. with timestamps.
345 if flag.print_status:
346 print('STATUS\t%r' % status)
347
348 return status
349
350
351def Batch(
352 cmd_ev, # type: cmd_eval.CommandEvaluator
353 c_parser, # type: cmd_parse.CommandParser
354 errfmt, # type: ui.ErrorFormatter
355 cmd_flags=0, # type: int
356):
357 # type: (...) -> int
358 """
359 source, eval, etc. treat parse errors as error code 2. But the --eval flag does not.
360 """
361 was_parsed, status = Batch2(cmd_ev, c_parser, errfmt, cmd_flags=cmd_flags)
362 if not was_parsed:
363 return 2
364 return status
365
366
367def Batch2(
368 cmd_ev, # type: cmd_eval.CommandEvaluator
369 c_parser, # type: cmd_parse.CommandParser
370 errfmt, # type: ui.ErrorFormatter
371 cmd_flags=0, # type: int
372):
373 # type: (...) -> Tuple[bool, int]
374 """Loop for batch execution.
375
376 Returns:
377 int status, e.g. 2 on parse error
378
379 Can this be combined with interactive loop? Differences:
380
381 - Handling of parse errors.
382 - Have to detect here docs at the end?
383
384 Not a problem:
385 - Get rid of --print-status and --show-ast for now
386 - Get rid of EOF difference
387
388 TODO:
389 - Do source / eval need this?
390 - 'source' needs to parse incrementally so that aliases are respected
391 - I doubt 'eval' does! You can test it.
392 - In contrast, 'trap' should parse up front?
393 - What about $() ?
394 """
395 was_parsed = True
396 status = 0
397 while True:
398 probe('main_loop', 'Batch_parse_enter')
399 try:
400 node = c_parser.ParseLogicalLine() # can raise ParseError
401 if node is None: # EOF
402 c_parser.CheckForPendingHereDocs() # can raise ParseError
403 break
404 except error.Parse as e:
405 errfmt.PrettyPrintError(e)
406 was_parsed = False
407 status = -1 # invalid value
408 break
409
410 # After every "logical line", no lines will be referenced by the Arena.
411 # Tokens in the LST still point to many lines, but lines with only comment
412 # or whitespace won't be reachable, so the GC will free them.
413 c_parser.arena.DiscardLines()
414
415 # Only optimize if we're on the last line like -c "echo hi" etc.
416 if (cmd_flags & cmd_eval.IsMainProgram and
417 c_parser.line_reader.LastLineHint()):
418 cmd_flags |= cmd_eval.OptimizeSubshells
419 if not cmd_ev.exec_opts.verbose_errexit():
420 cmd_flags |= cmd_eval.MarkLastCommands
421
422 probe('main_loop', 'Batch_parse_exit')
423
424 probe('main_loop', 'Batch_execute_enter')
425 # can't optimize this because we haven't seen the end yet
426 is_return, is_fatal = cmd_ev.ExecuteAndCatch(node, cmd_flags)
427 status = cmd_ev.LastStatus()
428 # e.g. 'return' in middle of script, or divide by zero
429 if is_return or is_fatal:
430 break
431 probe('main_loop', 'Batch_execute_exit')
432
433 probe('main_loop', 'Batch_collect_enter')
434 mylib.MaybeCollect() # manual GC point
435 probe('main_loop', 'Batch_collect_exit')
436
437 return was_parsed, status
438
439
440def ParseWholeFile(c_parser):
441 # type: (cmd_parse.CommandParser) -> command_t
442 """Parse an entire shell script.
443
444 This uses the same logic as Batch(). Used by:
445 - osh -n
446 - oshc translate
447 - Used by 'trap' to store code. But 'source' and 'eval' use Batch().
448
449 Note: it does NOT call DiscardLines
450 """
451 children = [] # type: List[command_t]
452 while True:
453 node = c_parser.ParseLogicalLine() # can raise ParseError
454 if node is None: # EOF
455 c_parser.CheckForPendingHereDocs() # can raise ParseError
456 break
457 children.append(node)
458
459 mylib.MaybeCollect() # manual GC point
460
461 if len(children) == 1:
462 return children[0]
463 else:
464 return command.CommandList(children)
465
466
467def EvalFile(
468 fs_path, # type: str
469 fd_state, # type: process.FdState
470 parse_ctx, # type: parse_lib.ParseContext
471 cmd_ev, # type: cmd_eval.CommandEvaluator
472 lang, # type: str
473):
474 # type: (...) -> Tuple[bool, int]
475 """Evaluate a disk file, for --eval --eval-pure
476
477 Copied and adapted from the 'source' builtin in builtin/meta_oils.py.
478
479 (Note that bind -x has to eval from a string, like Eval)
480
481 Raises:
482 util.HardExit
483 Returns:
484 ok: whether processing should continue
485 """
486 try:
487 f = fd_state.Open(fs_path)
488 except (IOError, OSError) as e:
489 print_stderr("%s: Couldn't open %r for --eval: %s" %
490 (lang, fs_path, posix.strerror(e.errno)))
491 return False, -1
492
493 line_reader = reader.FileLineReader(f, cmd_ev.arena)
494 c_parser = parse_ctx.MakeOshParser(line_reader)
495
496 # TODO:
497 # - Improve error locations
498 # - parse error should be fatal
499
500 with process.ctx_FileCloser(f):
501 with state.ctx_Eval(cmd_ev.mem, fs_path, None,
502 None): # set $0 to fs_path
503 with state.ctx_ThisDir(cmd_ev.mem, fs_path):
504 src = source.MainFile(fs_path)
505 with alloc.ctx_SourceCode(cmd_ev.arena, src):
506 # May raise util.HardExit
507 was_parsed, status = Batch2(cmd_ev, c_parser,
508 cmd_ev.errfmt)
509 if not was_parsed:
510 return False, -1
511
512 return True, status