overlord: terminal window resize function

Add terminal window resize function.
Embedded a control channel in the terminal websocket.

BUG=chromium:503673
TEST=1. Resize terminal window dynamically.
     2. Use vim to see the result.
     3. use very little font size to resize > 256 columns/rows
     4. Test both ghost.py and ghost.go

Change-Id: Id25fe6a8cf988ec640e0c88fce70825a5b95c71d
Reviewed-on: https://chromium-review.googlesource.com/284981
Tested-by: Hsu Wei-Cheng <mojahsu@chromium.org>
Reviewed-by: Wei-Ning Huang <wnhuang@chromium.org>
Commit-Queue: Wei-Ning Huang <wnhuang@chromium.org>
diff --git a/py/tools/ghost.py b/py/tools/ghost.py
index 3e29549..38614db 100755
--- a/py/tools/ghost.py
+++ b/py/tools/ghost.py
@@ -16,6 +16,8 @@
 import socket
 import subprocess
 import sys
+import struct
+import termios
 import threading
 import time
 import uuid
@@ -38,10 +40,12 @@
 _SHELL = os.getenv('SHELL', '/bin/bash')
 _DEFAULT_BIND_ADDRESS = '0.0.0.0'
 
+_CONTROL_START = 128
+_CONTROL_END = 129
+
 RESPONSE_SUCCESS = 'success'
 RESPONSE_FAILED = 'failed'
 
-
 class PingTimeoutError(Exception):
   pass
 
@@ -213,12 +217,12 @@
     except Exception:
       pass
 
-    raise RuntimeError("can't generate machine ID")
+    raise RuntimeError('can\'t generate machine ID')
 
   def Reset(self):
     """Reset state and clear request handlers."""
     self._reset.clear()
-    self._buf = ""
+    self._buf = ''
     self._last_ping = 0
     self._requests = {}
 
@@ -240,6 +244,19 @@
     msg = {'rid': omsg['rid'], 'response': status, 'params': params}
     self.SendMessage(msg)
 
+  def HandlePTYControl(self, fd, control_string):
+    msg = json.loads(control_string)
+    command = msg['command']
+    params = msg['params']
+    if command == 'resize':
+      # some error happened on websocket
+      if len(params) != 2:
+        return
+      winsize = struct.pack('HHHH', params[0], params[1], 0, 0)
+      fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
+    else:
+      logging.warn('Invalid request command "%s"', command)
+
   def SpawnPTYServer(self, _):
     """Spawn a PTY server and forward I/O to the TCP socket."""
     logging.info('SpawnPTYServer: started')
@@ -253,6 +270,9 @@
       os.execve(_SHELL, [_SHELL], env)
     else:
       try:
+        control_state = None
+        control_string = ''
+        write_buffer = ''
         while True:
           rd, _, _ = select.select([self._sock, fd], [], [])
 
@@ -262,8 +282,31 @@
           if self._sock in rd:
             ret = self._sock.recv(_BUFSIZE)
             if len(ret) == 0:
-              raise RuntimeError("socket closed")
-            os.write(fd, ret)
+              raise RuntimeError('socket closed')
+            while ret:
+              if control_state:
+                if chr(_CONTROL_END) in ret:
+                  index = ret.index(chr(_CONTROL_END))
+                  control_string += ret[:index]
+                  self.HandlePTYControl(fd, control_string)
+                  control_state = None
+                  control_string = ''
+                  ret = ret[index+1:]
+                else:
+                  control_string += ret
+                  ret = ''
+              else:
+                if chr(_CONTROL_START) in ret:
+                  control_state = _CONTROL_START
+                  index = ret.index(chr(_CONTROL_START))
+                  write_buffer += ret[:index]
+                  ret = ret[index+1:]
+                else:
+                  write_buffer += ret
+                  ret = ''
+            if write_buffer:
+              os.write(fd, write_buffer)
+              write_buffer = ''
       except (OSError, socket.error, RuntimeError):
         self._sock.close()
         logging.info('SpawnPTYServer: terminated')
@@ -290,7 +333,7 @@
         p.poll()
 
         if p.returncode != None:
-          raise RuntimeError("process complete")
+          raise RuntimeError('process complete')
 
         if p.stdout in rd:
           self._sock.send(p.stdout.read(_BUFSIZE))
@@ -301,7 +344,7 @@
         if self._sock in rd:
           ret = self._sock.recv(_BUFSIZE)
           if len(ret) == 0:
-            raise RuntimeError("socket closed")
+            raise RuntimeError('socket closed')
           p.stdin.write(ret)
     except (OSError, socket.error, RuntimeError):
       self._sock.close()
@@ -399,7 +442,7 @@
           self._reset.set()
           raise RuntimeError('Register request timeout')
         logging.info('Registered with Overlord at %s:%d', *non_local['addr'])
-        self._queue.put("pause", True)
+        self._queue.put('pause', True)
 
       try:
         logging.info('Trying %s:%d ...', *addr)
@@ -429,7 +472,7 @@
         self._connected_addr = addr
         self.Listen()
 
-    raise RuntimeError("Cannot connect to any server")
+    raise RuntimeError('Cannot connect to any server')
 
   def Reconnect(self):
     logging.info('Received reconnect request from RPC server, reconnecting...')
@@ -446,7 +489,7 @@
       try:
         s.bind(('0.0.0.0', _OVERLORD_LAN_DISCOVERY_PORT))
       except socket.error as e:
-        logging.error("LAN discovery: %s, abort", e)
+        logging.error('LAN discovery: %s, abort', e)
         return
 
       logging.info('LAN Discovery: started')
@@ -546,7 +589,7 @@
   parser.add_argument('--no-rpc-server', dest='rpc_server',
                       action='store_false', default=True,
                       help='disable RPC server')
-  parser.add_argument("--prop-file", dest="prop_file", type=str, default=None,
+  parser.add_argument('--prop-file', dest='prop_file', type=str, default=None,
                       help='file containing the JSON representation of client '
                            'properties')
   parser.add_argument('overlord_ip', metavar='OVERLORD_IP', type=str,