diff --git a/README.rst b/README.rst index 7085673..845d48d 100644 --- a/README.rst +++ b/README.rst @@ -71,6 +71,28 @@ These options both accept an integer millisecond value which will control the speed of the automated typing. +Canned Replay or Live Replay? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The default playback mode outputs back the 'canned' output text from the +original terminal session(s), without any side effects. However, the side +effects might be desirable during the presentation. + +For instance, when demoing a REST API, the presenter might want to show the +effects of the API calls on a service using a browser. Or the demoed code +could drive some other non-console output, like a visualisation or a game. + +The --live-replay option connects the prerecorded input to a live shell for +actual live output and side effects: + + $ pias play --live-replay + +This option is composable with the previous ones: + + $ pias play --live-replay --auto-type --auto-waypoint + +Live replay also works two or more joined terminal sessions. + JavaScript Player ~~~~~~~~~~~~~~~~~ @@ -96,3 +118,14 @@ that you should be aware of: * All terminals in a session should be the same size. This restriction may go away in the future. + + * The live-replay option has its own particularities: + + * Sessions created with the --append switch won't continue after the first + recording session ends. + + * Sometimes keypresses "bounce", and double characters get inserted. + + * Some live-replay output sequences lasting longer than the corresponding + output in the recording session can get buffered waiting for the next + user action. diff --git a/playitagainsam/__init__.py b/playitagainsam/__init__.py index beedc2a..061898c 100644 --- a/playitagainsam/__init__.py +++ b/playitagainsam/__init__.py @@ -140,6 +140,9 @@ def main(argv, env=None): parser.add_argument("--join", action="store_true", help="join an existing record/replay session", default=env.get("PIAS_OPT_JOIN", False)) + parser.add_argument("--shell", + help="the shell to execute when recording or live-replaying", + default=util.get_default_shell()) subparsers = parser.add_subparsers(dest="subcommand", title="subcommands") # The "record" command. @@ -147,9 +150,6 @@ def main(argv, env=None): parser_record.add_argument("datafile", nargs="?" if default_datafile else 1, default=[default_datafile]) - parser_record.add_argument("--shell", - help="the shell to execute", - default=util.get_default_shell()) datafile_opts = parser_record.add_mutually_exclusive_group() datafile_opts.add_argument("--append", action="store_true", help="append to an existing session file", @@ -172,6 +172,9 @@ def main(argv, env=None): parser_play.add_argument("--auto-waypoint", type=int, nargs="?", const=600, help="auto type newlines at this speed in ms", default=False) + parser_play.add_argument("--live-replay", action="store_true", + help="recorded input is passed to a live session, and recorded oputput is ignored", + default=False) # The "replay" alias for the "play" command. # Python2.7 argparse doesn't seem to have proper support for aliases. @@ -224,16 +227,18 @@ def err(msg, *args): try: if args.subcommand == "record": if not args.join: - eventlog = EventLog(args.datafile, "a" if args.append else "w") + eventlog = EventLog(args.datafile, "a" if args.append else "w", args.shell) recorder = Recorder(sock_path, eventlog, args.shell) recorder.start() join_recorder(sock_path) elif args.subcommand in ("play", "replay"): if not args.join: - eventlog = EventLog(args.datafile, "r") - player = Player(sock_path, eventlog, args.terminal, - args.auto_type, args.auto_waypoint) + eventlog = EventLog(args.datafile, "r", args.shell, live_replay=args.live_replay) + shell = args.shell or eventlog.shell + player = Player(sock_path, eventlog, args.terminal, + args.auto_type, args.auto_waypoint, + args.live_replay, args.shell) player.start() join_player(sock_path) diff --git a/playitagainsam/eventlog.py b/playitagainsam/eventlog.py index 91724a1..7b6b0dc 100644 --- a/playitagainsam/eventlog.py +++ b/playitagainsam/eventlog.py @@ -14,16 +14,24 @@ import six +from playitagainsam.util import get_default_shell + class EventLog(object): - def __init__(self, datafile, mode): + def __init__(self, datafile, mode, shell, live_replay=False): self.datafile = datafile self.mode = mode + self.live_replay = live_replay + self.shell = shell if mode == "r" or mode == "a": with open(self.datafile, "r") as f: data = json.loads(f.read()) self.events = data["events"] + # for compatibility with older recorded sessions, + # we'll get the default shell if none is in the eventlog + if live_replay: + self.shell = self.shell or data.get("shell", None) or get_default_shell() self._event_stream = None else: self.events = [] @@ -33,7 +41,7 @@ def close(self): dirnm, basenm = os.path.split(self.datafile) tf = NamedTemporaryFile(prefix=basenm, dir=dirnm, delete=False) with tf: - data = {"events": self.events} + data = {"events": self.events, "shell": self.shell} output = json.dumps(data, indent=2, sort_keys=True) tf.write(output.encode("utf8")) tf.flush() @@ -92,9 +100,13 @@ def _iter_events(self): if event["act"] == "ECHO": for c in event["data"]: yield {"act": "READ", "term": event["term"], "data": c} - yield {"act": "WRITE", "term": event["term"], "data": c} + if not self.live_replay: + yield {"act": "WRITE", "term": event["term"], "data": c} elif event["act"] == "READ": for c in event["data"]: yield {"act": "READ", "term": event["term"], "data": c} + elif event["act"] == "WRITE": + if not self.live_replay: + yield event else: yield event diff --git a/playitagainsam/player.py b/playitagainsam/player.py index 965d071..29f8f3a 100644 --- a/playitagainsam/player.py +++ b/playitagainsam/player.py @@ -14,6 +14,7 @@ import six from playitagainsam.util import forkexec, get_default_terminal +from playitagainsam.util import forkexec_pty from playitagainsam.util import get_pias_script, get_fd from playitagainsam.coordinator import SocketCoordinator, proxy_to_coordinator @@ -25,10 +26,12 @@ class Player(SocketCoordinator): waypoint_chars = (six.b("\n"), six.b("\r")) def __init__(self, sock_path, eventlog, terminal=None, auto_type=False, - auto_waypoint=False): + auto_waypoint=False, live_replay=False, replay_shell=None): super(Player, self).__init__(sock_path) self.eventlog = eventlog self.terminal = terminal or get_default_terminal() + self.live_replay = live_replay + self.replay_shell = replay_shell if not auto_type: self.auto_type = False else: @@ -38,26 +41,39 @@ def __init__(self, sock_path, eventlog, terminal=None, auto_type=False, else: self.auto_waypoint = auto_waypoint / 1000.0 self.terminals = {} - self.view_fds = {} + self.proc_fds = {} def run(self): event = self.eventlog.read_event() while event is not None: - if event["act"] == "OPEN": - self._do_open_terminal(event["term"]) - elif event["act"] == "CLOSE": - self._do_close_terminal(event["term"]) - elif event["act"] == "PAUSE": + action = event["act"] + term = event.get("term", None) + data = event.get("data", None) + + # TODO (JC) -- possibly this should not be in the event process loop: + # it should be event-driven by an asyncore.dispatcher.handle_read(); + # we would also ignore PAUSEs if (live-replay and not auto-type), + # but for now it works well enough for the patch author's use cases. + self._maybe_do_live_output(term) + + if action == "OPEN": + self._do_open_terminal(term) + elif action == "PAUSE": time.sleep(event["duration"]) - elif event["act"] == "READ": - self._do_read(event["term"], event["data"]) - elif event["act"] == "WRITE": - self._do_write(event["term"], event["data"]) + elif action == "READ": + self._do_read(term, data) + elif action == "WRITE": + # when in --live-replay mode, eventlog sends no WRITE events, + # so no need to check here whether we are on --live-replay or not + self._do_write(term, data) + if action == "CLOSE": + self._do_close_terminal(term) + event = self.eventlog.read_event() def cleanup(self): for term in self.terminals: - view_sock, = self.terminals[term] + view_sock, _, = self.terminals[term] view_sock.close() super(Player, self).cleanup() @@ -76,23 +92,43 @@ def _do_open_terminal(self, term): env["PIAS_OPT_TERMINAL"] = self.terminal forkexec([self.terminal, "-e", get_pias_script()], env) view_sock, _ = self.sock.accept() - self.terminals[term] = (view_sock,) + + if self.live_replay: + # this is cribbed from recorder._handle_open_terminal + # TODO (JC): look into further refactoring common code into an util function + # Fork a new shell behind a pty. + _, proc_fd = forkexec_pty([self.replay_shell]) + # often the terminal comes up before the pty has had a chance to send: + ready = None + while not ready: + ready = self.wait_for_data([proc_fd], 0.1) + else: + proc_fd = None + + self.terminals[term] = (view_sock, proc_fd) + self.proc_fds[proc_fd] = term def _do_close_terminal(self, term): - view_sock, = self.terminals[term] - self._do_read_waypoint(view_sock) + view_sock, proc_fd = self.terminals[term] view_sock.close() + # TODO (JC): would the pty still be open? close it? - def _do_read(self, term, wanted): - if isinstance(wanted, six.text_type): - wanted = wanted.encode("utf8") + def _do_read(self, term, recorded): + if isinstance(recorded, six.text_type): + recorded = recorded.encode("utf8") view_sock = self.terminals[term][0] - if wanted in self.waypoint_chars: - self._do_read_waypoint(view_sock) + if recorded in self.waypoint_chars: + self._do_read_waypoint(view_sock, term, recorded) else: - self._do_read_nonwaypoint(view_sock) + self._do_read_nonwaypoint(view_sock, term, recorded) + + def _maybe_live_replay(self, term, c=None): + if self.live_replay: + proc_fd = self.terminals[term][1] + if c: + os.write(proc_fd, c) - def _do_read_nonwaypoint(self, view_sock): + def _do_read_nonwaypoint(self, view_sock, term, recorded): # For non-waypoint characters, behaviour depends on auto-typing mode. # we can can either wait for the user to type something, or just # sleep briefly to simulate the typing. @@ -102,8 +138,9 @@ def _do_read_nonwaypoint(self, view_sock): c = view_sock.recv(1) while c in self.waypoint_chars: c = view_sock.recv(1) + self._maybe_live_replay(term, recorded) - def _do_read_waypoint(self, view_sock): + def _do_read_waypoint(self, view_sock, term, recorded): # For waypoint characters, behaviour depends on auto-waypoint mode. # Either we just proceed automatically, or the user must actually # type one before we proceed. @@ -113,6 +150,39 @@ def _do_read_waypoint(self, view_sock): c = view_sock.recv(1) while c not in self.waypoint_chars: c = view_sock.recv(1) + self._maybe_live_replay(term, recorded) + + def _maybe_do_live_output(self, term): + if self.live_replay: + # like self._do_open_terminal above, also cribbed from recorder.py + # TODO (JC): for the same reason, look into refactoring + ready = self.wait_for_data(self.proc_fds, 0.01) + # Process output from each ready process in turn. + for proc_fd in ready: + term = self.proc_fds[proc_fd] + view_fd = self.terminals[term][0].fileno() + # Loop through one character at a time, consuming as + # much output from the process as is available. + # We buffer it and write it to the eventlog as a single event, + # because multiple bytes might be part of a single utf8 char. + proc_ready = [proc_fd] + while proc_ready: + try: + c = self._read_one_byte(proc_fd) + except OSError: + self._do_close_terminal(term) + break + else: + os.write(view_fd, c) + proc_ready = self.wait_for_data([proc_fd], 0) + + ## TODO (JC): No reason for this to be a method. Refactor to utils + def _read_one_byte(self, fd): + """Read a single byte, or raise OSError on failure.""" + c = os.read(fd, 1) + if not c: + raise OSError + return c def _do_write(self, term, data): view_sock = self.terminals[term][0]