Enjoy, World! (initial release)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a295864
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+*.pyc
+__pycache__
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..13608e3
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,26 @@
+# How To Contribute
+
+Want to contribute? Great! First, read this page (including the small print at the end).
+
+### Before you contribute
+Before we can use your code, you must sign the
+[Google Individual Contributor License Agreement](https://developers.google.com/open-source/cla/individual?csw=1)
+(CLA), which you can do online. The CLA is necessary mainly because you own the
+copyright to your changes, even after your contribution becomes part of our
+codebase, so we need your permission to use and distribute your code. We also
+need to be sure of various other things—for instance that you'll tell us if you
+know that your code infringes on other people's patents. You don't have to sign
+the CLA until after you've submitted your code for review and a member has
+approved it, but you must do it before we can put your code into our codebase.
+Before you start working on a larger contribution, you should get in touch with
+us first through the issue tracker with your idea so that we can help out and
+possibly guide you. Coordinating up front makes it much easier to avoid
+frustration later on.
+
+### Code reviews
+All submissions, including submissions by project members, require review. We
+use Github pull requests for this purpose.
+
+### The small print
+Contributions made by corporations are covered by a different agreement than
+the one above, the Software Grant and Corporate Contributor License Agreement.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d9a10c0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,176 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..824cda4
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,7 @@
+include src/port*.py
+include src/tests/port*.py
+include README.md
+include LICENSE
+include CONTRIBUTING.md
+include setup.py
+include test.sh
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..029febf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,62 @@
+# Python portpicker module
+
+This module is useful finding unused network ports on a host.
+It supports both Python 2 and Python 3.
+
+It provides a Python implementation of PickUnusedPort.
+It can also be called via the command line for use in shell scripts.
+
+If your code can accept a bound TCP socket rather than a port number consider
+using `socket.bind(('localhost', 0))` to bind atomically to an available port
+rather than using this library at all.
+
+There is a race condition between picking a port and your application code
+binding to it. The use of a port server by all of your test code to avoid
+that problem is recommended on loaded test hosts running many tests at a time.
+
+Unless you are using a port server, subsequent calls to PickUnusedPort() to
+obtain an additional port are not guaranteed to return a unique port.
+
+### What is the optional port server?
+
+A port server is intended to be run as a daemon, for use by all processes
+running on the host. It coordinates uses of network ports by anything using
+a portpicker library. If you are using hosts as part of a test automation
+cluster, each one should run a port server as a daemon. You should set the
+`PORTSERVER_ADDRESS=@unittest-portserver` environment variable on all of your
+test runners so that portpicker makes use of it.
+
+A sample port server is included. This portserver implementation works but has
+not spent time in production. If you use it with good results please report
+back so that this statement can be updated to reflect that. :)
+
+A port server listens on a unix socket, reads a pid from a new connection,
+tests the ports it is managing and replies with a port assignment port for that
+pid. A port is only reclaimed for potential reassignment to another process
+after the process it was originally assigned to has died. Processes that need
+multiple ports can simply issue multiple requests and are guaranteed they will
+each be unique.
+
+## Typical usage:
+
+```python
+import portpicker
+test_port = portpicker.PickUnusedPort()
+```
+
+Or from the command line:
+
+```bash
+TEST_PORT=`/path/to/portpicker.py $$`
+```
+
+Or, if portpicker is installed as a library on the system Python interpreter:
+
+```bash
+TEST_PORT=`python3 -m portpicker $$`
+```
+
+## DISCLAIMER
+
+This is not an official Google product (experimental or otherwise), it is just
+code that happens to be owned by Google.
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..8e88a7b
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,53 @@
+"""Simple distutils setup for the pure Python portpicker."""
+
+import sys
+import textwrap
+import distutils.core
+
+
+def main():
+ requires = []
+ scripts = []
+ py_version = sys.version_info[:2]
+ if py_version < (3, 3):
+ requires.append('mock(>=1.0)')
+ if py_version == (3, 3):
+ requires.append('asyncio(>=3.4)')
+ if py_version >= (3, 3):
+ # The example portserver implementation requires Python 3 and asyncio.
+ scripts.append('src/portserver.py')
+
+ distutils.core.setup(
+ name='portpicker',
+ version='1.0.0',
+ description='A library to choose unique available network ports.',
+ long_description=textwrap.dedent("""
+ Portpicker provides an API to find and return an available network
+ port for an application to bind to. Ideally suited for use from
+ unittests or for test harnesses that launch a local server."""),
+ license='Apache 2.0',
+ maintainer='Google',
+ url='https://github.com/google/python_portpicker',
+ package_dir={'': 'src'},
+ py_modules=['portpicker'],
+ platforms=['POSIX'],
+ requires=requires,
+ scripts=scripts,
+ classifiers=[
+ 'Development Status :: 5 - Production/Stable',
+ 'License :: OSI Approved :: Apache Software License',
+ 'Intended Audience :: Developers',
+ 'Programming Language :: Python',
+ 'Programming Language :: Python 2',
+ 'Programming Language :: Python 2.7',
+ 'Programming Language :: Python 3',
+ 'Programming Language :: Python 3.3',
+ 'Programming Language :: Python 3.4',
+ 'Programming Language :: Python :: Implementation :: CPython',
+ 'Programming Language :: Python :: Implementation :: Jython',
+ 'Programming Language :: Python :: Implementation :: PyPy']
+ )
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/portpicker.py b/src/portpicker.py
new file mode 100755
index 0000000..751174d
--- /dev/null
+++ b/src/portpicker.py
@@ -0,0 +1,199 @@
+#!/usr/bin/python3
+#
+# Copyright 2007 Google Inc. All Rights Reserved.
+#
+# 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.
+
+"""Pure python code for finding unused ports on a host.
+
+This module provides a pure python implementation of PickUnusedPort.
+It can also be called via the command line for use in shell scripts.
+When called from the command line, it takes one optional argument, which,
+if given, is sent to portserver instead of portpicker's PID.
+To reserve a port for the lifetime of a bash script, use $BASHPID as this
+argument.
+
+There is a race condition between picking a port and your application code
+binding to it. The use of a port server to prevent that is recommended on
+loaded test hosts running many tests at a time.
+
+If your code can accept a bound socket as input rather than being handed a
+port number consider using socket.bind(('localhost', 0)) to bind to an
+available port without a race condition rather than using this library.
+
+Typical usage:
+ test_port = portpicker.PickUnusedPort()
+"""
+
+from __future__ import print_function
+import os
+import random
+import socket
+import sys
+
+
+_PROTOS = [(socket.SOCK_STREAM, socket.IPPROTO_TCP),
+ (socket.SOCK_DGRAM, socket.IPPROTO_UDP)]
+
+
+def Bind(port, socket_type, socket_proto):
+ """Try to bind to a socket of the specified type, protocol, and port.
+
+ This is primarily a helper function for PickUnusedPort, used to see
+ if a particular port number is available.
+
+ Args:
+ port: The port number to bind to, or 0 to have the OS pick a free port.
+ socket_type: The type of the socket (ex: socket.SOCK_STREAM).
+ socket_proto: The protocol of the socket (ex: socket.IPPROTO_TCP).
+
+ Returns:
+ The port number on success or None on failure.
+ """
+ s = socket.socket(socket.AF_INET, socket_type, socket_proto)
+ try:
+ try:
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ s.bind(('', port))
+ return s.getsockname()[1]
+ except socket.error:
+ return None
+ finally:
+ s.close()
+
+
+def IsPortFree(port):
+ """Check if specified port is free.
+
+ Args:
+ port: integer, port to check
+ Returns:
+ boolean, whether it is free to use for both TCP and UDP
+ """
+ return (Bind(port, _PROTOS[0][0], _PROTOS[0][1]) and
+ Bind(port, _PROTOS[1][0], _PROTOS[1][1]))
+
+
+def PickUnusedPort(pid=None):
+ """A pure python implementation of PickUnusedPort.
+
+ Args:
+ pid: PID to tell the portserver to associate the reservation with. If None,
+ the current process's PID is used.
+
+ Returns:
+ A port number that is unused on both TCP and UDP.
+ """
+ port = None
+ # Provide access to the portserver on an opt-in basis.
+ if 'PORTSERVER_ADDRESS' in os.environ:
+ port = _GetPortFromPortServer(os.environ['PORTSERVER_ADDRESS'], pid=pid)
+ if not port:
+ return _PickUnusedPortWithoutServer()
+ return port
+
+
+def _PickUnusedPortWithoutServer():
+ """A pure python implementation of PickUnusedPort_NoServer().
+
+ This code ensures that the port is available on both TCP and UDP.
+
+ This function is an implementation detail of PickUnusedPort(), and
+ should not be called by code outside of this module.
+
+ Returns:
+ A port number that is unused on both TCP and UDP. None on error.
+ """
+ # Try random ports first.
+ r = random.Random()
+ for _ in range(10):
+ port = int(r.randrange(32768, 60000))
+ if IsPortFree(port):
+ return port
+
+ # Try OS-assigned ports next.
+ # Ambrose discovered that on the 2.6 kernel, calling Bind() on UDP socket
+ # returns the same port over and over. So always try TCP first.
+ while True:
+ # Ask the OS for an unused port.
+ port = Bind(0, _PROTOS[0][0], _PROTOS[0][1])
+ # Check if this port is unused on the other protocol.
+ if port and Bind(port, _PROTOS[1][0], _PROTOS[1][1]):
+ return port
+
+
+def _GetPortFromPortServer(portserver_address, pid=None):
+ """Request a free a port from a system-wide portserver.
+
+ This follows a very simple portserver protocol:
+ The request consists of our pid (in ASCII) followed by a newline.
+ The response is a port number and a newline, 0 on failure.
+
+ This function is an implementation detail of PickUnusedPort(), and
+ should not be called by code outside of this module.
+
+ Args:
+ portserver_address: The address (path) of a unix domain socket
+ with which to connect to the portserver. A leading '@'
+ character indicates an address in the "abstract namespace."
+ pid: The PID to tell the portserver to associate the reservation with.
+ If None, the current process's PID is used.
+
+ Returns:
+ The port number on success or None on failure.
+ """
+ if not portserver_address:
+ return None
+ # An AF_UNIX address may start with a zero byte, in which case it is in the
+ # "abstract namespace", and doesn't have any filesystem representation.
+ # See 'man 7 unix' for details.
+ # The convention is to write '@' in the address to represent this zero byte.
+ if portserver_address[0] == '@':
+ portserver_address = '\0' + portserver_address[1:]
+
+ if pid is None:
+ pid = os.getpid()
+
+ try:
+ # Create socket.
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ try:
+ # Connect to portserver.
+ sock.connect(portserver_address)
+
+ # Write request.
+ sock.sendall(('%d\n' % pid).encode('ascii'))
+
+ # Read response.
+ # 1K should be ample buffer space.
+ buf = sock.recv(1024)
+ finally:
+ sock.close()
+ except socket.error:
+ print ('Socket error when connecting to portserver.', file=sys.stderr)
+ return None
+
+ try:
+ return int(buf.split(b'\n')[0])
+ except ValueError:
+ print ('Portserver failed to find a port.', file=sys.stderr)
+ return None
+
+
+if __name__ == '__main__':
+ # If passed an argument, cast it to int and treat it as a PID, otherwise pass
+ # pid=None to use portpicker's PID.
+ port = PickUnusedPort(pid=int(sys.argv[1]) if len(sys.argv) > 1 else None)
+ if not port:
+ sys.exit(1)
+ print (port)
diff --git a/src/portserver.py b/src/portserver.py
new file mode 100755
index 0000000..f24ab19
--- /dev/null
+++ b/src/portserver.py
@@ -0,0 +1,334 @@
+#!/usr/bin/python3
+#
+# Copyright 2015 Google Inc. All Rights Reserved.
+#
+# 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.
+
+"""A server to hand out network ports to applications running on one host.
+
+Typical usage:
+ 1) Run one instance of this process on each of your unittest farm hosts.
+ 2) Set the PORTSERVER_ADDRESS environment variable in your test runner
+ environment to let the portpicker library know to use a port server
+ rather than attempt to find ports on its own.
+
+$ /path/to/portserver.py &
+$ export PORTSERVER_ADDRESS=@unittest-portserver
+$ # ... launch a bunch of unittest runners using portpicker ...
+"""
+
+import argparse
+import asyncio
+import collections
+import logging
+import os
+import signal
+import socket
+import sys
+
+
+log = None # Initialized to a logging.Logger by _configure_logging().
+
+
+def _get_process_command_line(pid):
+ try:
+ with open('/proc/{}/cmdline'.format(pid), 'rt') as cmdline_f:
+ return cmdline_f.read()
+ except IOError:
+ return ''
+
+
+def _get_process_start_time(pid):
+ try:
+ with open('/proc/{}/stat'.format(pid), 'rt') as pid_stat_f:
+ return int(pid_stat_f.readline().split()[21])
+ except IOError:
+ return 0
+
+
+def _port_is_available(port):
+ """Return False if the given network port is currently in use."""
+ for socket_type, proto in ((socket.SOCK_STREAM, socket.IPPROTO_TCP),
+ (socket.SOCK_DGRAM, 0)):
+ sock = None
+ try:
+ sock = socket.socket(socket.AF_INET, socket_type, proto)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sock.bind(('', port))
+ if socket_type == socket.SOCK_STREAM:
+ sock.listen(1)
+ except socket.error:
+ return False
+ finally:
+ if sock:
+ sock.close()
+ return True
+
+
+def _should_allocate_port(pid):
+ """Determine if we should allocate a port for use by the given process id."""
+ if pid <= 0:
+ log.info('Not allocating a port to invalid pid')
+ return False
+ if pid == 1:
+ # The client probably meant to send us its parent pid but
+ # had been reparented to init.
+ log.info('Not allocating a port to init.')
+ return False
+ try:
+ os.kill(pid, 0)
+ except ProcessLookupError:
+ log.info('Not allocating a port to a non-existent process')
+ return False
+ return True
+
+
+class _PortInfo(object):
+ """Container class for information about a given port assignment.
+
+ Attributes:
+ port: integer port number
+ pid: integer process id or 0 if unassigned.
+ start_time: Time in seconds since the epoch that the process started.
+ """
+
+ __slots__ = ('port', 'pid', 'start_time')
+
+ def __init__(self, port):
+ self.port = port
+ self.pid = 0
+ self.start_time = 0
+
+
+class _PortPool(object):
+ """Manage available ports for processes.
+
+ Ports are reclaimed when the reserving process exits and the reserved port
+ is no longer in use. Only ports which are free for both TCP and UDP will be
+ handed out. It is easier to not differentiate between protocols.
+
+ The pool must be pre-seeded with add_port_to_free_pool() calls
+ after which get_port_for_process() will allocate and reclaim ports.
+ The len() of a _PortPool returns the total number of ports being managed.
+
+ Attributes:
+ ports_checked_for_last_request: The number of ports examined in order to
+ return from the most recent get_port_for_process() request. A high
+ number here likely means the number of available ports with no active
+ process using them is getting low.
+ """
+
+ def __init__(self):
+ self._port_queue = collections.deque()
+ self.ports_checked_for_last_request = 0
+
+ def num_ports(self):
+ return len(self._port_queue)
+
+ def get_port_for_process(self, pid):
+ """Allocates and returns port for pid or 0 if none could be allocated."""
+ if not self._port_queue:
+ raise RuntimeError('No ports being managed.')
+
+ # Avoid an infinite loop if all ports are currently assigned.
+ check_count = 0
+ max_ports_to_test = len(self._port_queue)
+ while check_count < max_ports_to_test:
+ # Get the next candidate port and move it to the back of the queue.
+ candidate = self._port_queue.pop()
+ self._port_queue.appendleft(candidate)
+ check_count += 1
+ if (candidate.start_time == 0 or
+ candidate.start_time != _get_process_start_time(candidate.pid)):
+ if _port_is_available(candidate.pid):
+ candidate.pid = pid
+ candidate.start_time = _get_process_start_time(pid)
+ if not candidate.start_time:
+ log.info("Can't read start time for pid %d.", pid)
+ self.ports_checked_for_last_request = check_count
+ return candidate.port
+ else:
+ log.info('Port %d unexpectedly in use, last owning pid %d.',
+ candidate.port, candidate.pid)
+
+ log.info('All ports in use.')
+ self.ports_checked_for_last_request = check_count
+ return 0
+
+ def add_port_to_free_pool(self, port):
+ """Add a new port to the free pool for allocation."""
+ if port < 1 or port > 65535:
+ raise ValueError('Port must be in the [1, 65535] range, not %d.' % port)
+ port_info = _PortInfo(port=port)
+ self._port_queue.append(port_info)
+
+
+class _PortServerRequestHandler(object):
+ """A class to handle port allocation and status requests.
+
+ Allocates ports to process ids via the dead simple port server protocol
+ when the handle_port_request asyncio.coroutine handler has been registered.
+ Statistics can be logged using the dump_stats method.
+ """
+
+ def __init__(self, ports_to_serve):
+ """Initialize a new port server.
+
+ Args:
+ ports_to_serve: A sequence of unique port numbers to test and offer
+ up to clients.
+ """
+ self._port_pool = _PortPool()
+ self._total_allocations = 0
+ self._denied_allocations = 0
+ self._client_request_errors = 0
+ for port in ports_to_serve:
+ self._port_pool.add_port_to_free_pool(port)
+
+ @asyncio.coroutine
+ def handle_port_request(self, reader, writer):
+ client_data = yield from reader.read(100)
+ self._handle_port_request(client_data, writer)
+ writer.close()
+
+ def _handle_port_request(self, client_data, writer):
+ """Given a port request body, parse it and respond appropriately.
+
+ Args:
+ client_data: The request bytes from the client.
+ writer: The asyncio Writer for the response to be written to.
+ """
+ try:
+ pid = int(client_data)
+ except ValueError as error:
+ self._client_request_errors += 1
+ log.warning('Could not parse request: %s', error)
+ return
+
+ log.info('Request on behalf of pid %d.', pid)
+ log.info('cmdline: %s', _get_process_command_line(pid))
+
+ if not _should_allocate_port(pid):
+ self._denied_allocations += 1
+ return
+
+ port = self._port_pool.get_port_for_process(pid)
+ if port > 0:
+ self._total_allocations += 1
+ writer.write('{:d}\n'.format(port).encode('utf-8'))
+ log.debug('Allocated port %d to pid %d', port, pid)
+ else:
+ self._denied_allocations += 1
+
+ def dump_stats(self):
+ """Logs statistics of our operation."""
+ log.info('Dumping statistics:')
+ stats = []
+ stats.append(
+ 'client-request-errors {}'.format(self._client_request_errors))
+ stats.append('denied-allocations {}'.format(self._denied_allocations))
+ stats.append('num-ports-managed {}'.format(self._port_pool.num_ports()))
+ stats.append(
+ 'num-ports-checked-for-last-request {}'.format(
+ self._port_pool.ports_checked_for_last_request))
+ stats.append('total-allocations {}'.format(self._total_allocations))
+ for stat in stats:
+ log.info(stat)
+
+
+def _parse_command_line():
+ """Configure and parse our command line flags."""
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ '--portserver_static_pool', type=str,
+ default='32768-60000',
+ help='Comma separated N-P Range(s) of ports to manage.')
+ parser.add_argument(
+ '--portserver_unix_socket_address', type=str,
+ default='@unittest-portserver',
+ help='Address of AF_UNIX socket on which to listen (first @ is a NUL).')
+ parser.add_argument('--verbose', action='store_true',
+ default=False, help='Enable verbose messages.')
+ parser.add_argument('--debug', action='store_true',
+ default=False, help='Enable full debug messages.')
+ return parser.parse_args(sys.argv[1:])
+
+
+def _parse_port_ranges(pool_str):
+ """Given a 'N-P,X-Y' description of port ranges, return a set of ints."""
+ ports = set()
+ for range_str in pool_str.split(','):
+ try:
+ a, b = range_str.split('-', 1)
+ start, end = int(a), int(b)
+ except ValueError:
+ log.error('Ignoring unparsable port range %r.', range_str)
+ continue
+ if start < 1 or end > 65535:
+ log.error('Ignoring out of bounds port range %r.', range_str)
+ continue
+ ports.update(set(range(start, end + 1)))
+ return ports
+
+
+def _configure_logging(verbose=False, debug=False):
+ """Configure the log global, message format, and verbosity settings."""
+ overall_level = logging.DEBUG if debug else logging.INFO
+ logging.basicConfig(
+ format=('{levelname[0]}{asctime}.{msecs:03.0f} {thread} '
+ '{filename}:{lineno}] {message}'),
+ datefmt='%m%d %H:%M:%S', style='{', level=overall_level)
+ global log
+ log = logging.getLogger('portserver')
+ # The verbosity controls our loggers logging level, not the global
+ # one above. This avoids debug messages from libraries such as asyncio.
+ log.setLevel(logging.DEBUG if verbose else overall_level)
+
+
+def main():
+ config = _parse_command_line()
+ if config.debug:
+ asyncio.tasks._DEBUG = True # Equivalent of PYTHONASYNCIODEBUG=1 in 3.4; pylint: disable=protected-access
+ _configure_logging(verbose=config.verbose, debug=config.debug)
+ ports_to_serve = _parse_port_ranges(config.portserver_static_pool)
+ if not ports_to_serve:
+ log.error('No ports. Invalid port ranges in --portserver_static_pool?')
+ sys.exit(1)
+
+ request_handler = _PortServerRequestHandler(ports_to_serve)
+
+ event_loop = asyncio.get_event_loop()
+ event_loop.add_signal_handler(signal.SIGUSR1, request_handler.dump_stats)
+ coro = asyncio.start_unix_server(
+ request_handler.handle_port_request,
+ path=config.portserver_unix_socket_address.replace('@', '\0', 1),
+ loop=event_loop)
+ server_address = config.portserver_unix_socket_address
+
+ server = event_loop.run_until_complete(coro)
+ log.info('Serving on %s', server_address)
+ try:
+ event_loop.run_forever()
+ except KeyboardInterrupt:
+ log.info('Stopping due to ^C.')
+
+ server.close()
+ event_loop.run_until_complete(server.wait_closed())
+ event_loop.remove_signal_handler(signal.SIGUSR1)
+ event_loop.close()
+ request_handler.dump_stats()
+ log.info('Goodbye.')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/tests/portpicker_test.py b/src/tests/portpicker_test.py
new file mode 100755
index 0000000..d711329
--- /dev/null
+++ b/src/tests/portpicker_test.py
@@ -0,0 +1,138 @@
+#!/usr/bin/python
+#
+# Copyright 2007 Google Inc. All Rights Reserved.
+#
+# 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.
+
+"""Unittests for the portpicker module."""
+
+import os
+import random
+import socket
+import unittest
+
+try:
+ # pylint: disable=no-name-in-module
+ from unittest import mock # Python >= 3.3.
+except ImportError:
+ import mock # https://pypi.python.org/pypi/mock
+
+import portpicker
+
+
+class PickUnusedPortTest(unittest.TestCase):
+
+ def IsUnusedTCPPort(self, port):
+ return self._bind(port, socket.SOCK_STREAM, socket.IPPROTO_TCP)
+
+ def IsUnusedUDPPort(self, port):
+ return self._bind(port, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+
+ def setUp(self):
+ # So we can Bind even if portpicker.Bind is stubbed out.
+ self._bind = portpicker.Bind
+
+ def testPickUnusedPortActuallyWorks(self):
+ """This test can be flaky."""
+ for _ in range(10):
+ port = portpicker.PickUnusedPort()
+ self.assertTrue(self.IsUnusedTCPPort(port))
+ self.assertTrue(self.IsUnusedUDPPort(port))
+
+ @unittest.skipIf('PORTSERVER_ADDRESS' not in os.environ,
+ 'no port server to test against')
+ def testPickUnusedCanSuccessfullyUsePortServer(self):
+
+ with mock.patch.object(portpicker, '_PickUnusedPortWithoutServer'):
+ portpicker._PickUnusedPortWithoutServer.side_effect = Exception('eek!')
+
+ # Since _PickUnusedPortWithoutServer() raises an exception, if we
+ # can successfully obtain a port, the portserver must be working.
+ port = portpicker.PickUnusedPort()
+ self.assertTrue(self.IsUnusedTCPPort(port))
+ self.assertTrue(self.IsUnusedUDPPort(port))
+
+ @unittest.skipIf('PORTSERVER_ADDRESS' not in os.environ,
+ 'no port server to test against')
+ def testGetPortFromPortServer(self):
+ """Exercise the _GetPortFromPortServer() helper function."""
+ for _ in range(10):
+ port = portpicker._GetPortFromPortServer(os.environ['PORTSERVER_ADDRESS'])
+ self.assertTrue(self.IsUnusedTCPPort(port))
+ self.assertTrue(self.IsUnusedUDPPort(port))
+
+ def testSendsPidToPortServer(self):
+ server = mock.Mock()
+ server.recv.return_value = b'42768\n'
+ with mock.patch.object(socket, 'socket', return_value=server):
+ port = portpicker._GetPortFromPortServer('portserver', pid=1234)
+ server.sendall.assert_called_once_with(b'1234\n')
+ self.assertEqual(port, 42768)
+
+ def testPidDefaultsToOwnPid(self):
+ server = mock.Mock()
+ server.recv.return_value = b'52768\n'
+ with mock.patch.object(socket, 'socket', return_value=server):
+ with mock.patch.object(os, 'getpid', return_value=9876):
+ port = portpicker._GetPortFromPortServer('portserver')
+ server.sendall.assert_called_once_with(b'9876\n')
+ self.assertEqual(port, 52768)
+
+ def testRandomlyChosenPorts(self):
+ # Unless this box is under an overwhelming socket load, this test
+ # will heavily exercise the "pick a port randomly" part of the
+ # port picking code, but may never hit the "OS assigns a port"
+ # code.
+ for _ in range(100):
+ port = portpicker._PickUnusedPortWithoutServer()
+ self.assertTrue(self.IsUnusedTCPPort(port))
+ self.assertTrue(self.IsUnusedUDPPort(port))
+
+ def testOSAssignedPorts(self):
+ self.last_assigned_port = None
+
+ def ErrorForExplicitPorts(port, socket_type, socket_proto):
+ # Only successfully return a port if an OS-assigned port is
+ # requested, or if we're checking that the last OS-assigned port
+ # is unused on the other protocol.
+ if port == 0 or port == self.last_assigned_port:
+ self.last_assigned_port = self._bind(port, socket_type, socket_proto)
+ return self.last_assigned_port
+ else:
+ return None
+
+ with mock.patch.object(portpicker, 'Bind', ErrorForExplicitPorts):
+ for _ in range(100):
+ port = portpicker._PickUnusedPortWithoutServer()
+ self.assertTrue(self.IsUnusedTCPPort(port))
+ self.assertTrue(self.IsUnusedUDPPort(port))
+
+ def testPickPortsWithError(self):
+ r = random.Random()
+
+ def BindWithError(port, socket_type, socket_proto):
+ # 95% failure rate means both port picking methods will be exercised.
+ if int(r.uniform(0, 20)) == 0:
+ return self._bind(port, socket_type, socket_proto)
+ else:
+ return None
+
+ with mock.patch.object(portpicker, 'Bind', BindWithError):
+ for _ in range(100):
+ port = portpicker._PickUnusedPortWithoutServer()
+ self.assertTrue(self.IsUnusedTCPPort(port))
+ self.assertTrue(self.IsUnusedUDPPort(port))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/src/tests/portserver_test.py b/src/tests/portserver_test.py
new file mode 100755
index 0000000..835fc67
--- /dev/null
+++ b/src/tests/portserver_test.py
@@ -0,0 +1,215 @@
+#!/usr/bin/python3
+#
+# Copyright 2015 Google Inc. All Rights Reserved.
+#
+# 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.
+
+"""Tests for the example portserver."""
+
+import asyncio
+import os
+import socket
+import sys
+import unittest
+from unittest import mock
+
+import portpicker
+import portserver
+
+
+
+def setUpModule():
+ portserver._configure_logging(verbose=True)
+
+
+class PortserverFunctionsTest(unittest.TestCase):
+
+ @classmethod
+ def setUp(cls):
+ cls.port = portpicker.PickUnusedPort()
+
+ def test_get_process_command_line(self):
+ portserver._get_process_command_line(os.getpid())
+
+ def test_get_process_start_time(self):
+ self.assertGreater(portserver._get_process_start_time(os.getpid()), 0)
+
+ def test_port_is_available_true(self):
+ """This might be flaky unless this test is run with a portserver."""
+ # Insert Inception "we must go deeper" meme here.
+ self.assertTrue(portserver._port_is_available(self.port))
+
+ def test_port_is_available_false(self):
+ with mock.patch.object(socket, 'socket') as mock_sock:
+ mock_sock.side_effect = socket.error('fake socket error', 0)
+ self.assertFalse(portserver._port_is_available(self.port))
+
+ def test_should_allocate_port(self):
+ self.assertFalse(portserver._should_allocate_port(0))
+ self.assertFalse(portserver._should_allocate_port(1))
+ self.assertTrue(portserver._should_allocate_port, os.getpid())
+ child_pid = os.fork()
+ if child_pid == 0:
+ os._exit(0)
+ else:
+ os.waitpid(child_pid, 0)
+ # This test assumes that after waitpid returns the kernel has finished
+ # cleaning the process. We also assume that the kernel will not reuse
+ # the former child's pid before our next call checks for its existence.
+ # Likely assumptions, but not guaranteed.
+ self.assertFalse(portserver._should_allocate_port(child_pid))
+
+ def test_parse_command_line(self):
+ with mock.patch.object(
+ sys, 'argv', ['program_name', '--verbose',
+ '--portserver_static_pool=1-1,3-8',
+ '--portserver_unix_socket_address=@hello-test']):
+ portserver._parse_command_line()
+
+ def test_parse_port_ranges(self):
+ self.assertFalse(portserver._parse_port_ranges(''))
+ self.assertCountEqual(portserver._parse_port_ranges('1-1'), {1})
+ self.assertCountEqual(portserver._parse_port_ranges('1-1,3-8,375-378'),
+ {1, 3, 4, 5, 6, 7, 8, 375, 376, 377, 378})
+ # Unparsable parts are logged but ignored.
+ self.assertEqual({1, 2}, portserver._parse_port_ranges('1-2,not,numbers'))
+ self.assertEqual(set(), portserver._parse_port_ranges('8080-8081x'))
+ # Port ranges that go out of bounds are logged but ignored.
+ self.assertEqual(set(), portserver._parse_port_ranges('0-1138'))
+ self.assertEqual(set(range(19, 84 + 1)),
+ portserver._parse_port_ranges('1138-65536,19-84'))
+
+ def test_configure_logging(self):
+ """Just code coverage really."""
+ portserver._configure_logging(False)
+ portserver._configure_logging(True)
+
+ @mock.patch.object(sys, 'argv',
+ ['PortserverFunctionsTest.test_main',
+ '--portserver_unix_socket_address=@TST-%d' % os.getpid()])
+ @mock.patch.object(portserver, '_parse_port_ranges')
+ @mock.patch('asyncio.get_event_loop')
+ @mock.patch('asyncio.start_unix_server')
+ def test_main(self, *unused_mocks):
+ portserver._parse_port_ranges.return_value = set()
+ with self.assertRaises(SystemExit):
+ portserver.main()
+
+ # Give it at least one port and try again.
+ portserver._parse_port_ranges.return_value = {self.port}
+
+ mock_event_loop = mock.Mock(spec=asyncio.base_events.BaseEventLoop)
+ asyncio.get_event_loop.return_value = mock_event_loop
+ asyncio.start_unix_server.return_value = mock.Mock()
+ mock_event_loop.run_forever.side_effect = KeyboardInterrupt
+
+ portserver.main()
+
+ mock_event_loop.run_until_complete.assert_any_call(
+ asyncio.start_unix_server.return_value)
+ mock_event_loop.close.assert_called_once_with()
+ # NOTE: This could be improved. Tests of main() are often gross.
+
+
+class PortPoolTest(unittest.TestCase):
+
+ @classmethod
+ def setUpClass(cls):
+ cls.port = portpicker.PickUnusedPort()
+
+ def setUp(self):
+ self.pool = portserver._PortPool()
+
+ def test_initialization(self):
+ self.assertEqual(0, self.pool.num_ports())
+ self.pool.add_port_to_free_pool(self.port)
+ self.assertEqual(1, self.pool.num_ports())
+ self.pool.add_port_to_free_pool(1138)
+ self.assertEqual(2, self.pool.num_ports())
+ self.assertRaises(ValueError, self.pool.add_port_to_free_pool, 0)
+ self.assertRaises(ValueError, self.pool.add_port_to_free_pool, 65536)
+
+ @mock.patch.object(portserver, '_port_is_available')
+ def test_get_port_for_process_ok(self, mock_port_is_available):
+ self.pool.add_port_to_free_pool(self.port)
+ mock_port_is_available.return_value = True
+ self.assertEqual(self.port, self.pool.get_port_for_process(os.getpid()))
+ self.assertEqual(1, self.pool.ports_checked_for_last_request)
+
+ @mock.patch.object(portserver, '_port_is_available')
+ def test_get_port_for_process_none_left(self, mock_port_is_available):
+ self.pool.add_port_to_free_pool(self.port)
+ self.pool.add_port_to_free_pool(22)
+ mock_port_is_available.return_value = False
+ self.assertEqual(2, self.pool.num_ports())
+ self.assertEqual(0, self.pool.get_port_for_process(os.getpid()))
+ self.assertEqual(2, self.pool.num_ports())
+ self.assertEqual(2, self.pool.ports_checked_for_last_request)
+
+
+@mock.patch.object(portserver, '_get_process_command_line')
+@mock.patch.object(portserver, '_should_allocate_port')
+@mock.patch.object(portserver._PortPool, 'get_port_for_process')
+class PortServerRequestHandlerTest(unittest.TestCase):
+
+ def setUp(self):
+ portserver._configure_logging(verbose=True)
+ self.rh = portserver._PortServerRequestHandler([23, 42, 54])
+
+ def test_stats_reporting(self, *unused_mocks):
+ with mock.patch.object(portserver, 'log') as mock_logger:
+ self.rh.dump_stats()
+ mock_logger.info.assert_called_with('total-allocations 0')
+
+ def test_handle_port_request_bad_data(self, *unused_mocks):
+ self._test_bad_data_from_client(b'')
+ self._test_bad_data_from_client(b'\n')
+ self._test_bad_data_from_client(b'99Z\n')
+ self._test_bad_data_from_client(b'99 8\n')
+ self.assertEqual([], portserver._get_process_command_line.mock_calls)
+
+ def _test_bad_data_from_client(self, data):
+ mock_writer = mock.Mock(asyncio.StreamWriter)
+ self.rh._handle_port_request(data, mock_writer)
+ self.assertFalse(portserver._should_allocate_port.mock_calls)
+
+ def test_handle_port_request_denied_allocation(self, *unused_mocks):
+ portserver._should_allocate_port.return_value = False
+ self.assertEqual(0, self.rh._denied_allocations)
+ mock_writer = mock.Mock(asyncio.StreamWriter)
+ self.rh._handle_port_request(b'5\n', mock_writer)
+ self.assertEqual(1, self.rh._denied_allocations)
+
+ def test_handle_port_request_bad_port_returned(self, *unused_mocks):
+ portserver._should_allocate_port.return_value = True
+ self.rh._port_pool.get_port_for_process.return_value = 0
+ mock_writer = mock.Mock(asyncio.StreamWriter)
+ self.rh._handle_port_request(b'6\n', mock_writer)
+ self.rh._port_pool.get_port_for_process.assert_called_once_with(6)
+ self.assertEqual(1, self.rh._denied_allocations)
+
+ def test_handle_port_request_success(self, *unused_mocks):
+ portserver._should_allocate_port.return_value = True
+ self.rh._port_pool.get_port_for_process.return_value = 999
+ mock_writer = mock.Mock(asyncio.StreamWriter)
+ self.assertEqual(0, self.rh._total_allocations)
+ self.rh._handle_port_request(b'8', mock_writer)
+ portserver._should_allocate_port.assert_called_once_with(8)
+ self.rh._port_pool.get_port_for_process.assert_called_once_with(8)
+ self.assertEqual(1, self.rh._total_allocations)
+ self.assertEqual(0, self.rh._denied_allocations)
+ mock_writer.write.assert_called_once_with(b'999\n')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/test.sh b/test.sh
new file mode 100755
index 0000000..eac4fac
--- /dev/null
+++ b/test.sh
@@ -0,0 +1,21 @@
+#!/bin/sh -ex
+
+echo 'TESTING under Python 2'
+mkdir -p build/test_envs/python2
+virtualenv --python=python2 build/test_envs/python2
+build/test_envs/python2/bin/pip install mock
+# Without --upgrade pip won't copy local changes over to a new test install
+# unless you've updated the package version number.
+build/test_envs/python2/bin/pip install --upgrade .
+build/test_envs/python2/bin/python2 src/tests/portpicker_test.py
+
+echo 'TESTING under Python 3'
+mkdir -p build/test_envs/python3
+virtualenv --python=python3 build/test_envs/python3
+build/test_envs/python3/bin/pip install --upgrade .
+build/test_envs/python3/bin/python3 src/tests/portpicker_test.py
+
+echo 'TESTING the portserver'
+PYTHONPATH=src build/test_envs/python3/bin/python3 src/tests/portserver_test.py
+
+echo PASS