Skip to content

Commit

Permalink
add --live-replay option
Browse files Browse the repository at this point in the history
 * --live-replay switch is ignored when a terminal is joining.
 * live replay tries to use the shell that was used for recording.
 * recorder now registers the shell used for recording in the eventlog.
 * older eventlogs have no shell property, so replay uses default shell.
 * --shell option allows to override the shell selection for live_replay.
 * known bug: when typing, sometimes there is a bounce and double characters
   get inserted.
 * known bug: live-replay output sequences lasting longer than the recording
   get buffered waiting for the next user action.
 * known bug: when live-replaying appended logs, pias crashes out as it
   tries to play an eventlog past an exit in the pty
 * could use some testing with older eventlog recordings.
 * could use some testing with multi-terminal live replays.
  • Loading branch information
candeira committed Feb 14, 2014
1 parent 708a666 commit 6e0867d
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 33 deletions.
33 changes: 33 additions & 0 deletions README.rst
Expand Up @@ -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 <input-file> --live-replay

This option is composable with the previous ones:

$ pias play <input-file> --live-replay --auto-type --auto-waypoint

Live replay also works two or more joined terminal sessions.

JavaScript Player
~~~~~~~~~~~~~~~~~

Expand All @@ -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.
19 changes: 12 additions & 7 deletions playitagainsam/__init__.py
Expand Up @@ -140,16 +140,16 @@ 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.
parser_record = subparsers.add_parser("record")
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",
Expand All @@ -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.
Expand Down Expand Up @@ -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)

Expand Down
18 changes: 15 additions & 3 deletions playitagainsam/eventlog.py
Expand Up @@ -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 = []
Expand All @@ -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()
Expand Down Expand Up @@ -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
116 changes: 93 additions & 23 deletions playitagainsam/player.py
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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()

Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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]
Expand Down

0 comments on commit 6e0867d

Please sign in to comment.