blob: fc274066aa7a1e82294f12d3909d517cdbcb974f [file] [log] [blame]
#!/usr/bin/env python
#
# Copyright 2007 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Serves content for "script" handlers using an HTTP runtime.
http_runtime supports two ways to start the runtime instance.
START_PROCESS sends the runtime_config protobuf (serialized and base64 encoded
as not all platforms support binary data over stdin) to the runtime instance
over stdin and requires the runtime instance to send the port it is listening on
over stdout.
START_PROCESS_FILE creates two temporary files and adds the paths of both files
to the runtime instance command line. The first file is written by http_runtime
with the runtime_config proto (serialized); the runtime instance is expected to
delete the file after reading it. The second file is written by the runtime
instance with the port it is listening on (the line must be newline terminated);
http_runtime is expected to delete the file after reading it.
TODO: convert all runtimes to START_PROCESS_FILE.
"""
import base64
import contextlib
import httplib
import logging
import os
import socket
import subprocess
import sys
import time
import threading
import urllib
import wsgiref.headers
from google.appengine.tools.devappserver2 import http_runtime_constants
from google.appengine.tools.devappserver2 import instance
from google.appengine.tools.devappserver2 import login
from google.appengine.tools.devappserver2 import safe_subprocess
from google.appengine.tools.devappserver2 import tee
from google.appengine.tools.devappserver2 import util
START_PROCESS = -1
START_PROCESS_FILE = -2
def _sleep_between_retries(attempt, max_attempts, sleep_base):
"""Sleep between retry attempts.
Do an exponential backoff between retry attempts on an operation. The general
pattern for use is:
for attempt in range(max_attempts):
# Try operation, either return or break on success
_sleep_between_retries(attempt, max_attempts, sleep_base)
Args:
attempt: Which attempt just failed (0 based).
max_attempts: The maximum number of attempts that will be made.
sleep_base: How long in seconds to sleep between the first and second
attempt (the time will be doubled between each successive attempt). The
value may be any numeric type that is convertible to float (complex
won't work but user types that are sufficiently numeric-like will).
"""
# Don't sleep after the last attempt as we're about to give up.
if attempt < (max_attempts - 1):
time.sleep((2 ** attempt) * sleep_base)
def _remove_retry_sharing_violation(path, max_attempts=10, sleep_base=.125):
"""Removes a file (with retries on Windows for sharing violations).
Args:
path: The filesystem path to remove.
max_attempts: The maximum number of attempts to try to remove the path
before giving up.
sleep_base: How long in seconds to sleep between the first and second
attempt (the time will be doubled between each successive attempt). The
value may be any numeric type that is convertible to float (complex
won't work but user types that are sufficiently numeric-like will).
Raises:
WindowsError: When an error other than a sharing violation occurs.
"""
if sys.platform == 'win32':
for attempt in range(max_attempts):
try:
os.remove(path)
break
except WindowsError as e:
import winerror
# Sharing violations are expected to occasionally occur when the runtime
# instance is context swapped after writing the port but before closing
# the file. Ignore these and try again.
if e.winerror != winerror.ERROR_SHARING_VIOLATION:
raise
_sleep_between_retries(attempt, max_attempts, sleep_base)
else:
logging.warn('Unable to delete %s', path)
else:
os.remove(path)
class HttpRuntimeProxy(instance.RuntimeProxy):
"""Manages a runtime subprocess used to handle dynamic content."""
_VALID_START_PROCESS_FLAVORS = [START_PROCESS, START_PROCESS_FILE]
def __init__(self, args, runtime_config_getter, module_configuration,
env=None, start_process_flavor=START_PROCESS):
"""Initializer for HttpRuntimeProxy.
Args:
args: Arguments to use to start the runtime subprocess.
runtime_config_getter: A function that can be called without arguments
and returns the runtime_config_pb2.Config containing the configuration
for the runtime.
module_configuration: An application_configuration.ModuleConfiguration
instance respresenting the configuration of the module that owns the
runtime.
env: A dict of environment variables to pass to the runtime subprocess.
start_process_flavor: Which version of start process to start your
runtime process. SUpported flavors are START_PROCESS and
START_PROCESS_FILE.
Raises:
ValueError: An unknown value for start_process_flavor was used.
"""
super(HttpRuntimeProxy, self).__init__()
self._host = 'localhost'
self._port = None
self._process = None
self._process_lock = threading.Lock() # Lock to guard self._process.
self._prior_error = None
self._stderr_tee = None
self._runtime_config_getter = runtime_config_getter
self._args = args
self._module_configuration = module_configuration
self._env = env
if start_process_flavor not in self._VALID_START_PROCESS_FLAVORS:
raise ValueError('Invalid start_process_flavor.')
self._start_process_flavor = start_process_flavor
def _get_error_file(self):
for error_handler in self._module_configuration.error_handlers or []:
if not error_handler.error_code or error_handler.error_code == 'default':
return os.path.join(self._module_configuration.application_root,
error_handler.file)
else:
return None
def handle(self, environ, start_response, url_map, match, request_id,
request_type):
"""Serves this request by forwarding it to the runtime process.
Args:
environ: An environ dict for the request as defined in PEP-333.
start_response: A function with semantics defined in PEP-333.
url_map: An appinfo.URLMap instance containing the configuration for the
handler matching this request.
match: A re.MatchObject containing the result of the matched URL pattern.
request_id: A unique string id associated with the request.
request_type: The type of the request. See instance.*_REQUEST module
constants.
Yields:
A sequence of strings containing the body of the HTTP response.
"""
if self._prior_error:
yield self._handle_error(self._prior_error, start_response)
return
environ[http_runtime_constants.SCRIPT_HEADER] = match.expand(url_map.script)
if request_type == instance.BACKGROUND_REQUEST:
environ[http_runtime_constants.REQUEST_TYPE_HEADER] = 'background'
elif request_type == instance.SHUTDOWN_REQUEST:
environ[http_runtime_constants.REQUEST_TYPE_HEADER] = 'shutdown'
elif request_type == instance.INTERACTIVE_REQUEST:
environ[http_runtime_constants.REQUEST_TYPE_HEADER] = 'interactive'
for name in http_runtime_constants.ENVIRONS_TO_PROPAGATE:
if http_runtime_constants.INTERNAL_ENVIRON_PREFIX + name not in environ:
value = environ.get(name, None)
if value is not None:
environ[
http_runtime_constants.INTERNAL_ENVIRON_PREFIX + name] = value
headers = util.get_headers_from_environ(environ)
if environ.get('QUERY_STRING'):
url = '%s?%s' % (urllib.quote(environ['PATH_INFO']),
environ['QUERY_STRING'])
else:
url = urllib.quote(environ['PATH_INFO'])
if 'CONTENT_LENGTH' in environ:
headers['CONTENT-LENGTH'] = environ['CONTENT_LENGTH']
data = environ['wsgi.input'].read(int(environ['CONTENT_LENGTH']))
else:
data = ''
cookies = environ.get('HTTP_COOKIE')
user_email, admin, user_id = login.get_user_info(cookies)
if user_email:
nickname, organization = user_email.split('@', 1)
else:
nickname = ''
organization = ''
headers[http_runtime_constants.REQUEST_ID_HEADER] = request_id
headers[http_runtime_constants.INTERNAL_HEADER_PREFIX + 'User-Id'] = (
user_id)
headers[http_runtime_constants.INTERNAL_HEADER_PREFIX + 'User-Email'] = (
user_email)
headers[
http_runtime_constants.INTERNAL_HEADER_PREFIX + 'User-Is-Admin'] = (
str(int(admin)))
headers[
http_runtime_constants.INTERNAL_HEADER_PREFIX + 'User-Nickname'] = (
nickname)
headers[
http_runtime_constants.INTERNAL_HEADER_PREFIX + 'User-Organization'] = (
organization)
headers['X-AppEngine-Country'] = 'ZZ'
connection = httplib.HTTPConnection(self._host, self._port)
with contextlib.closing(connection):
try:
connection.connect()
connection.request(environ.get('REQUEST_METHOD', 'GET'),
url,
data,
dict(headers.items()))
try:
response = connection.getresponse()
except httplib.HTTPException as e:
# The runtime process has written a bad HTTP response. For example,
# a Go runtime process may have crashed in app-specific code.
yield self._handle_error(
'the runtime process gave a bad HTTP response: %s' % e,
start_response)
return
# Ensures that we avoid merging repeat headers into a single header,
# allowing use of multiple Set-Cookie headers.
headers = []
for name in response.msg:
for value in response.msg.getheaders(name):
headers.append((name, value))
response_headers = wsgiref.headers.Headers(headers)
error_file = self._get_error_file()
if (error_file and
http_runtime_constants.ERROR_CODE_HEADER in response_headers):
try:
with open(error_file) as f:
content = f.read()
except IOError:
content = 'Failed to load error handler'
logging.exception('failed to load error file: %s', error_file)
start_response('500 Internal Server Error',
[('Content-Type', 'text/html'),
('Content-Length', str(len(content)))])
yield content
return
del response_headers[http_runtime_constants.ERROR_CODE_HEADER]
start_response('%s %s' % (response.status, response.reason),
response_headers.items())
# Yield the response body in small blocks.
while True:
try:
block = response.read(512)
if not block:
break
yield block
except httplib.HTTPException:
# The runtime process has encountered a problem, but has not
# necessarily crashed. For example, a Go runtime process' HTTP
# handler may have panicked in app-specific code (which the http
# package will recover from, so the process as a whole doesn't
# crash). At this point, we have already proxied onwards the HTTP
# header, so we cannot retroactively serve a 500 Internal Server
# Error. We silently break here; the runtime process has presumably
# already written to stderr (via the Tee).
break
except Exception:
with self._process_lock:
if self._process and self._process.poll() is not None:
# The development server is in a bad state. Log and return an error
# message.
self._prior_error = ('the runtime process for the instance running '
'on port %d has unexpectedly quit' % (
self._port))
yield self._handle_error(self._prior_error, start_response)
else:
raise
def _handle_error(self, message, start_response):
# Give the runtime process a bit of time to write to stderr.
time.sleep(0.1)
buf = self._stderr_tee.get_buf()
if buf:
message = message + '\n\n' + buf
# TODO: change 'text/plain' to 'text/plain; charset=utf-8'
# throughout devappserver2.
start_response('500 Internal Server Error',
[('Content-Type', 'text/plain'),
('Content-Length', str(len(message)))])
return message
def _read_start_process_file(self, max_attempts=10, sleep_base=.125):
"""Read the single line response expected in the start process file.
The START_PROCESS_FILE flavor uses a file for the runtime instance to
report back the port it is listening on. We can't rely on EOF semantics
as that is a race condition when the runtime instance is simultaneously
writing the file while the devappserver process is reading it; rather we
rely on the line being terminated with a newline.
Args:
max_attempts: The maximum number of attempts to read the line.
sleep_base: How long in seconds to sleep between the first and second
attempt (the time will be doubled between each successive attempt). The
value may be any numeric type that is convertible to float (complex
won't work but user types that are sufficiently numeric-like will).
Returns:
If a full single line (as indicated by a newline terminator) is found, all
data read up to that point is returned; return an empty string if no
newline is read before the process exits or the max number of attempts are
made.
"""
try:
for attempt in range(max_attempts):
# Yes, the final data may already be in the file even though the
# process exited. That said, since the process should stay alive
# if it's exited we don't care anyway.
if self._process.poll() is not None:
return ''
# On Mac, if the first read in this process occurs before the data is
# written, no data will ever be read by this process without the seek.
self._process.child_out.seek(0)
line = self._process.child_out.read()
if '\n' in line:
return line
_sleep_between_retries(attempt, max_attempts, sleep_base)
finally:
self._process.child_out.close()
return ''
def start(self):
"""Starts the runtime process and waits until it is ready to serve."""
runtime_config = self._runtime_config_getter()
# TODO: Use a different process group to isolate the child process
# from signals sent to the parent. Only available in subprocess in
# Python 2.7.
assert self._start_process_flavor in self._VALID_START_PROCESS_FLAVORS
if self._start_process_flavor == START_PROCESS:
serialized_config = base64.b64encode(runtime_config.SerializeToString())
with self._process_lock:
assert not self._process, 'start() can only be called once'
self._process = safe_subprocess.start_process(
self._args,
serialized_config,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=self._env,
cwd=self._module_configuration.application_root)
line = self._process.stdout.readline()
elif self._start_process_flavor == START_PROCESS_FILE:
serialized_config = runtime_config.SerializeToString()
with self._process_lock:
assert not self._process, 'start() can only be called once'
self._process = safe_subprocess.start_process_file(
args=self._args,
input_string=serialized_config,
env=self._env,
cwd=self._module_configuration.application_root,
stderr=subprocess.PIPE)
line = self._read_start_process_file()
_remove_retry_sharing_violation(self._process.child_out.name)
# _stderr_tee may be pre-set by unit tests.
if self._stderr_tee is None:
self._stderr_tee = tee.Tee(self._process.stderr, sys.stderr)
self._stderr_tee.start()
self._prior_error = None
self._port = None
try:
self._port = int(line)
except ValueError:
self._prior_error = 'bad runtime process port [%r]' % line
logging.error(self._prior_error)
else:
# Check if the runtime can serve requests.
if not self._can_connect():
self._prior_error = 'cannot connect to runtime on port %r' % self._port
logging.error(self._prior_error)
def _can_connect(self):
connection = httplib.HTTPConnection(self._host, self._port)
with contextlib.closing(connection):
try:
connection.connect()
except socket.error:
return False
else:
return True
def quit(self):
"""Causes the runtime process to exit."""
with self._process_lock:
assert self._process, 'module was not running'
try:
self._process.kill()
except OSError:
pass
# Mac leaks file descriptors without call to join. Suspect a race
# condition where the interpreter is unable to close the subprocess pipe
# as the thread hasn't returned from the readline call.
self._stderr_tee.join(5)
self._process = None