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

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