Skip to content

Commit

Permalink
Async get_events, handle_event, handle_readables, `handle_writa…
Browse files Browse the repository at this point in the history
…bles` (#769)

* Asynchronous `handle_event` and `LocalExecutor` thread

* Bail out on first task completion

* mypy

* Add `helper/benchmark.sh` and fix threaded which must now use asyncio (reduced performance of threaded)

* Print open file diff from `benchmark.sh`

* Add `--local-executor` flag, disabled by default for now until tests are updated

* Async `handle_readables` and `handle_writables` for `HttpProtocolHandlerPlugin` interface (doesnt impact proxy/web plugins for now)

* Async `get_events`

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address tests after async changes

* mypy and flake8

* spelldoc

* `check.py` and trailing comma

* Rename to `_assertions.py`

* Add missing `pytest-mock` and `pytest-asyncio` deps

* Add `pytest-mock` to `pylint` deps

* Correct use of `parameterize` and add `PT007` to flake8 ignores

* Fix mypy hints broken for `< Python3.9`

* Remove usage of `asynccontextmanager` which is not available for all Python versions that `proxy.py` supports

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix for pre-python-3.9 versions

* `AsyncTask` apis `set_name` and `get_name` are not available on all supported versions

* Install setuptools via `lib-dep` until we recommend editable install

* Deprecate support for `Python 3.6`

* Use recommendation suggested here https://github.com/abhinavsingh/proxy.py/pull/769\#discussion_r753840929

* Address recommendation here https://github.com/abhinavsingh/proxy.py/pull/769\#discussion_r753841906

* Make `Threadless` agnostic of `multiprocessing.Process`

* Acceptors must dispatch to local executor in non-blocking fashion

* No daemon for executor processes and fix shutdown logic

* Only return fds from `_selected_events` not all events data

* Refactor logic

* Prefix private methods with `_`

* `work_queue` and not `client_queue`

* Turn `Threadless` into an abstract executor. Introduce `RemoteExecutor`

* Make `LocalExecutor` agnostic of `threading.Thread`

* `LocalExecutor` now implements `Threadless`

* `get_events` and `get_descriptors` now must return int and not sock.  `Threadless` now avoids repeated register/unregister and instead make use of `selectors.modify`

* Fix `main` tests

* Apply suggestions from code review

Co-authored-by: Sviatoslav Sydorenko <wk@sydorenko.org.ua>

* Apply code review recommendations manually

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Revert back `Any` and use `addr or None`

* Address `flake8`

* Update tests to use `fileno`

* Fix doc build

* Fix doc spell, use tear down and not teardown

* Doc updates

* Add back support for `Python 3.6`

* Acceptors dont need loop initialization

* On Python 3.6 `asyncio.new_event_loop()` is necessary

* Make doc happy

* `--threaded` needs a new event loop for 3.7 too

* Always use `asyncio.new_event_loop()` for threaded mode

Added e2e integration tests (subprocess & curl) for all modes.

* Lint fixes

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Sviatoslav Sydorenko <wk@sydorenko.org.ua>
  • Loading branch information
3 people authored Nov 23, 2021
1 parent d554b94 commit 44d7243
Show file tree
Hide file tree
Showing 43 changed files with 1,675 additions and 1,095 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"python.linting.pylintEnabled": true,
"python.linting.pylintArgs": ["--generate-members"],
"python.linting.flake8Enabled": true,
"python.linting.flake8Args": ["--config", ".flake8"],
"python.linting.mypyEnabled": true,
"python.formatting.provider": "autopep8",
"autoDocstring.docstringFormat": "sphinx"
Expand Down
11 changes: 7 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,7 @@ endif
.PHONY: container container-run container-release
.PHONY: devtools dashboard dashboard-clean

all:
echo $(IMAGE_TAG)
# lib-test
all: lib-test

https-certificates:
# Generate server key
Expand Down Expand Up @@ -94,7 +92,8 @@ lib-dep:
-r requirements.txt \
-r requirements-testing.txt \
-r requirements-release.txt \
-r requirements-tunnel.txt \
-r requirements-tunnel.txt && \
pip install "setuptools>=42"

lib-lint:
python -m tox -e lint
Expand Down Expand Up @@ -128,6 +127,7 @@ lib-coverage:
$(OPEN) htmlcov/index.html

lib-profile:
ulimit -n 65536 && \
sudo py-spy record \
-o profile.svg \
-t -F -s -- \
Expand All @@ -137,6 +137,9 @@ lib-profile:
--disable-http-proxy \
--enable-web-server \
--plugin proxy.plugin.WebServerPlugin \
--local-executor \
--backlog 65536 \
--open-file-limit 65536
--log-file /dev/null

devtools:
Expand Down
102 changes: 71 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
- [Setup Local Environment](#setup-local-environment)
- [Setup Git Hooks](#setup-git-hooks)
- [Sending a Pull Request](#sending-a-pull-request)
- [Benchmarks](#benchmarks)
- [Flags](#flags)
- [Changelog](#changelog)
- [v2.x](#v2x)
Expand All @@ -126,36 +127,56 @@

```console
# On Macbook Pro 2019 / 2.4 GHz 8-Core Intel Core i9 / 32 GB RAM
❯ hey -n 10000 -c 100 http://localhost:8899/http-route-example

Summary:
Total: 0.3248 secs
Slowest: 0.1007 secs
Fastest: 0.0002 secs
Average: 0.0028 secs
Requests/sec: 30784.7958

Total data: 190000 bytes
Size/request: 19 bytes

Response time histogram:
0.000 [1] |
0.010 [9533] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
0.020 [384] |■■

Latency distribution:
10% in 0.0004 secs
25% in 0.0007 secs
50% in 0.0013 secs
75% in 0.0029 secs
90% in 0.0057 secs
95% in 0.0097 secs
99% in 0.0185 secs

Status code distribution:
[200] 10000 responses
❯ ./helper/benchmark.sh
CONCURRENCY: 100 workers, TOTAL REQUESTS: 100000 req, QPS: 5000 req/sec, TIMEOUT: 1 sec

Summary:
Total: 3.1560 secs
Slowest: 0.0375 secs
Fastest: 0.0006 secs
Average: 0.0031 secs
Requests/sec: 31685.9140

Total data: 1900000 bytes
Size/request: 19 bytes

Response time histogram:
0.001 [1] |
0.004 [91680] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
0.008 [7929] |■■■
0.012 [263] |
0.015 [29] |
0.019 [8] |
0.023 [23] |
0.026 [15] |
0.030 [27] |
0.034 [16] |
0.037 [9] |


Latency distribution:
10% in 0.0022 secs
25% in 0.0025 secs
50% in 0.0029 secs
75% in 0.0034 secs
90% in 0.0041 secs
95% in 0.0048 secs
99% in 0.0066 secs

Details (average, fastest, slowest):
DNS+dialup: 0.0000 secs, 0.0006 secs, 0.0375 secs
DNS-lookup: 0.0000 secs, 0.0000 secs, 0.0000 secs
req write: 0.0000 secs, 0.0000 secs, 0.0046 secs
resp wait: 0.0030 secs, 0.0006 secs, 0.0320 secs
resp read: 0.0000 secs, 0.0000 secs, 0.0029 secs

Status code distribution:
[200] 100000 responses
```

PS: `proxy.py` and benchmark tools are running on the same machine during the above load test.
Checkout the repo and try it for yourself. See [Benchmarks](#benchmarks) for more details.

- Lightweight
- Uses only `~5-20MB` RAM
- No external dependency other than standard Python library
Expand Down Expand Up @@ -1977,13 +1998,21 @@ Every pull request is tested using GitHub actions.
See [GitHub workflow](https://github.com/abhinavsingh/proxy.py/tree/develop/.github/workflows)
for list of tests.

# Benchmarks

Simply run the following command from repo root to start benchmark

```console
❯ ./helper/benchmark.sh
```

# Flags

```console
❯ proxy -h
usage: -m [-h] [--enable-events] [--enable-conn-pool] [--threadless]
[--threaded] [--num-workers NUM_WORKERS] [--backlog BACKLOG]
[--hostname HOSTNAME] [--port PORT]
[--threaded] [--num-workers NUM_WORKERS] [--local-executor]
[--backlog BACKLOG] [--hostname HOSTNAME] [--port PORT]
[--unix-socket-path UNIX_SOCKET_PATH]
[--num-acceptors NUM_ACCEPTORS] [--version] [--log-level LOG_LEVEL]
[--log-file LOG_FILE] [--log-format LOG_FORMAT]
Expand All @@ -2009,7 +2038,7 @@ usage: -m [-h] [--enable-events] [--enable-conn-pool] [--threadless]
[--filtered-url-regex-config FILTERED_URL_REGEX_CONFIG]
[--cloudflare-dns-mode CLOUDFLARE_DNS_MODE]

proxy.py v2.3.2
proxy.py v2.3.2.dev193+g87ff921.d20211121

options:
-h, --help show this help message and exit
Expand All @@ -2026,6 +2055,13 @@ options:
handle each client connection.
--num-workers NUM_WORKERS
Defaults to number of CPU cores.
--local-executor Default: False. Disabled by default. When enabled
acceptors will make use of local (same process)
executor instead of distributing load across remote
(other process) executors. Enable this option to
achieve CPU affinity between acceptors and executors,
instead of using underlying OS kernel scheduling
algorithm.
--backlog BACKLOG Default: 100. Maximum number of pending connections to
proxy server
--hostname HOSTNAME Default: ::1. Server IP address.
Expand Down Expand Up @@ -2155,6 +2191,10 @@ https://github.com/abhinavsingh/proxy.py/issues/new

# Changelog

## v2.4.0

- No longer support `Python 3.6` due to `asyncio.run` usage in the core.

## v2.x

- No longer ~~a single file module~~.
Expand Down
24 changes: 13 additions & 11 deletions check.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,18 @@
sys.exit(1)

# Update README.md flags section to match current library --help output
# lib_help = subprocess.check_output(
# ['python', '-m', 'proxy', '-h']
# )
# with open('README.md', 'rb+') as f:
# c = f.read()
# pre_flags, post_flags = c.split(b'# Flags')
# help_text, post_changelog = post_flags.split(b'# Changelog')
# f.seek(0)
# f.write(pre_flags + b'# Flags\n\n```console\n\xe2\x9d\xaf proxy -h\n' + lib_help + b'```' +
# b'\n# Changelog' + post_changelog)
lib_help = subprocess.check_output(
['python', '-m', 'proxy', '-h'],
)
with open('README.md', 'rb+') as f:
c = f.read()
pre_flags, post_flags = c.split(b'# Flags')
help_text, post_changelog = post_flags.split(b'# Changelog')
f.seek(0)
f.write(
pre_flags + b'# Flags\n\n```console\n\xe2\x9d\xaf proxy -h\n' + lib_help + b'```' +
b'\n\n# Changelog' + post_changelog,
)

# Version is also hardcoded in README.md flags section
readme_version_cmd = 'cat README.md | grep "proxy.py v" | tail -2 | head -1 | cut -d " " -f 2 | cut -c2-'
Expand All @@ -72,7 +74,7 @@
# Doesn't contain "v" prefix
readme_version = readme_version_output.decode().strip()

if readme_version != lib_version[1:].split('-')[0]:
if readme_version != lib_version:
print(
'Version mismatch found. {0} (readme) vs {1} (lib).'.format(
readme_version, lib_version,
Expand Down
14 changes: 11 additions & 3 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,11 @@

nitpicky = True
_any_role = 'any'
_py_obj_role = 'py:obj'
_py_class_role = 'py:class'
nitpick_ignore = [
(_any_role, '<proxy.HttpProxyBasePlugin>'),
(_any_role, '__init__'),
(_any_role, '--threadless'),
(_any_role, 'Client'),
(_any_role, 'event_queue'),
(_any_role, 'fd_queue'),
Expand All @@ -256,8 +256,10 @@
(_any_role, 'HttpParser.state'),
(_any_role, 'HttpProtocolHandler'),
(_any_role, 'multiprocessing.Manager'),
(_any_role, 'work_klass'),
(_any_role, 'proxy.core.base.tcp_upstream.TcpUpstreamConnectionHandler'),
(_any_role, 'work_klass'),
(_py_class_role, '_asyncio.Task'),
(_py_class_role, 'asyncio.events.AbstractEventLoop'),
(_py_class_role, 'CacheStore'),
(_py_class_role, 'HttpParser'),
(_py_class_role, 'HttpProtocolHandlerPlugin'),
Expand All @@ -268,11 +270,17 @@
(_py_class_role, 'paramiko.channel.Channel'),
(_py_class_role, 'proxy.http.parser.parser.T'),
(_py_class_role, 'proxy.plugin.cache.store.base.CacheStore'),
(_py_class_role, 'proxy.core.pool.AcceptorPool'),
(_py_class_role, 'proxy.core.executors.ThreadlessPool'),
(_py_class_role, 'proxy.core.acceptor.threadless.T'),
(_py_class_role, 'queue.Queue[Any]'),
(_py_class_role, 'TcpClientConnection'),
(_py_class_role, 'TcpServerConnection'),
(_py_class_role, 'unittest.case.TestCase'),
(_py_class_role, 'unittest.result.TestResult'),
(_py_class_role, 'UUID'),
(_py_class_role, 'WebsocketFrame'),
(_py_class_role, 'Url'),
(_py_class_role, 'WebsocketFrame'),
(_py_class_role, 'Work'),
(_py_obj_role, 'proxy.core.acceptor.threadless.T'),
]
5 changes: 2 additions & 3 deletions examples/web_scraper.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
:license: BSD, see LICENSE for more details.
"""
import time
import socket

from typing import Dict

Expand Down Expand Up @@ -40,11 +39,11 @@ class WebScraper(Work):
only PUBSUB protocol.
"""

def get_events(self) -> Dict[socket.socket, int]:
async def get_events(self) -> Dict[int, int]:
"""Return sockets and events (read or write) that we are interested in."""
return {}

def handle_events(
async def handle_events(
self,
readables: Readables,
writables: Writables,
Expand Down
77 changes: 77 additions & 0 deletions helper/benchmark.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/bin/bash
#
# proxy.py
# ~~~~~~~~
# ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable
# proxy server for Application debugging, testing and development.
#
# :copyright: (c) 2013-present by Abhinav Singh and contributors.
# :license: BSD, see LICENSE for more details.
#
usage() {
echo "Usage: ./helper/benchmark.sh"
echo "You must run this script from proxy.py repo root."
}

DIRNAME=$(dirname "$0")
if [ "$DIRNAME" != "./helper" ]; then
usage
exit 1
fi

BASENAME=$(basename "$0")
if [ "$BASENAME" != "benchmark.sh" ]; then
usage
exit 1
fi

PWD=$(pwd)
if [ $(basename $PWD) != "proxy.py" ]; then
usage
exit 1
fi

TIMEOUT=1
QPS=20000
CONCURRENCY=100
TOTAL_REQUESTS=100000
OPEN_FILE_LIMIT=65536
BACKLOG=OPEN_FILE_LIMIT
PID_FILE=/tmp/proxy.pid

ulimit -n $OPEN_FILE_LIMIT

# time python -m \
# proxy \
# --enable-web-server \
# --plugin proxy.plugin.WebServerPlugin \
# --backlog $BACKLOG \
# --open-file-limit $OPEN_FILE_LIMIT \
# --pid-file $PID_FILE \
# --log-file /dev/null

PID=$(cat $PID_FILE)
if [[ -z "$PID" ]]; then
echo "Either pid file doesn't exist or no pid found in the pid file"
exit 1
fi
ADDR=$(lsof -Pan -p $PID -i | grep -v COMMAND | awk '{ print $9 }')

PRE_RUN_OPEN_FILES=$(./helper/monitor_open_files.sh)

echo "CONCURRENCY: $CONCURRENCY workers, TOTAL REQUESTS: $TOTAL_REQUESTS req, QPS: $QPS req/sec, TIMEOUT: $TIMEOUT sec"
hey \
-n $TOTAL_REQUESTS \
-c $CONCURRENCY \
-q $QPS \
-t $TIMEOUT \
http://$ADDR/http-route-example

POST_RUN_OPEN_FILES=$(./helper/monitor_open_files.sh)

echo $output

echo "Open files diff:"
diff <( echo "$PRE_RUN_OPEN_FILES" ) <( echo "$POST_RUN_OPEN_FILES" )

# while true; do netstat -ant | grep .8899 | awk '{print $6}' | sort | uniq -c | sort -n; sleep 1; done
2 changes: 1 addition & 1 deletion helper/monitor_open_files.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/bin/bash

#
# proxy.py
# ~~~~~~~~
# ⚡⚡⚡ Fast, Lightweight, Programmable, TLS interception capable
Expand Down
Loading

0 comments on commit 44d7243

Please sign in to comment.