Patchwork D2852: wireproto: implement basic frame reading and processing

login
register
mail settings
Submitter phabricator
Date March 14, 2018, 2:50 a.m.
Message ID <differential-rev-PHID-DREV-pwizb3lfwtxdnu2cnvvq-req@phab.mercurial-scm.org>
Download mbox | patch
Permalink /patch/29477/
State Superseded
Headers show

Comments

phabricator - March 14, 2018, 2:50 a.m.
indygreg created this revision.
Herald added a subscriber: mercurial-devel.
Herald added a reviewer: hg-reviewers.

REVISION SUMMARY
  We just implemented support for writing frames. Now let's implement
  support for reading them.
  
  The bulk of the new code is for a class that maintains the state of
  a server. Essentially, you construct an instance, feed frames to it,
  and it tells you what you should do next. The design is inspired by
  the "sans I/O" movement and the reactor pattern. We don't want to
  perform I/O or any major blocking event during frame ingestion because
  this arbitrarily limits ways that server pieces can be implemented.
  For example, it makes it much harder to swap in an alternate
  implementation based on asyncio or do crazy things like have requests
  dispatch to other processes.
  
  We do still implement readframe() which does I/O. But it is decoupled
  from the server reactor.
  
  Because testing server-side ingest is useful and difficult on running
  servers, we create a new "debugreflect" endpoint that will echo back
  to the client what was received and how it was interpreted. This could
  be useful for a server admin, someone implementing a client. But
  immediately, it is useful for testing: we're able to demonstrate that
  frames are parsed correctly and turned into requests to run commands
  without having to implement command dispatch on the server!

REPOSITORY
  rHG Mercurial

REVISION DETAIL
  https://phab.mercurial-scm.org/D2852

AFFECTED FILES
  mercurial/configitems.py
  mercurial/util.py
  mercurial/wireprotoframing.py
  mercurial/wireprotoserver.py
  tests/test-http-api-httpv2.t

CHANGE DETAILS




To: indygreg, #hg-reviewers
Cc: mercurial-devel
phabricator - March 14, 2018, 7:21 p.m.
indygreg abandoned this revision.
indygreg added inline comments.

INLINE COMMENTS

> wireprotoframing.py:81-87
> +def readframe(fh):
> +    """Read a unified framing protocol frame from a file object.
> +
> +    Returns a 3-tuple of (type, flags, payload) for the decoded frame or
> +    None if no frame is available. May raise if a malformed frame is
> +    seen.
> +    """

I have a minor refactor of this code locally so we can better support "sans I/O." Will send a new version once someone starts to look at this series.

REPOSITORY
  rHG Mercurial

REVISION DETAIL
  https://phab.mercurial-scm.org/D2852

To: indygreg, #hg-reviewers
Cc: mercurial-devel
phabricator - March 24, 2018, 2:49 p.m.
yuja added inline comments.

INLINE COMMENTS

> util.py:2569
> +        res = self.read(len(b))
> +        if res is None:
> +            return None

I think read() never returns None.

> wireprotoserver.py:402
> +        action, meta = reactor.onframerecv(frametype, frameflags, payload)
> +        states.append(json.dumps((action, meta), sort_keys=True,
> +                                 separators=(', ', ': ')))

This wouldn't work on Python 3 because JSON requires unicode.

REPOSITORY
  rHG Mercurial

REVISION DETAIL
  https://phab.mercurial-scm.org/D2852

To: indygreg, #hg-reviewers, durin42
Cc: yuja, mercurial-devel

Patch

diff --git a/tests/test-http-api-httpv2.t b/tests/test-http-api-httpv2.t
--- a/tests/test-http-api-httpv2.t
+++ b/tests/test-http-api-httpv2.t
@@ -261,7 +261,7 @@ 
   > allow-push = *
   > EOF
 
-  $ hg -R server serve -p $HGPORT -d --pid-file hg.pid
+  $ hg -R server serve -p $HGPORT -d --pid-file hg.pid -E error.log
   $ cat hg.pid > $DAEMON_PIDS
 
 Authorized request for valid read-write command works
@@ -314,3 +314,441 @@ 
   s>     Content-Length: 42\r\n
   s>     \r\n
   s>     unknown wire protocol command: badcommand\n
+
+debugreflect isn't enabled by default
+
+  $ send << EOF
+  > httprequest POST api/$HTTPV2/ro/debugreflect
+  >     user-agent: test
+  > EOF
+  using raw connection to peer
+  s>     POST /api/exp-http-v2-0001/ro/debugreflect HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     user-agent: test\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     \r\n
+  s> makefile('rb', None)
+  s>     HTTP/1.1 404 Not Found\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: text/plain\r\n
+  s>     Content-Length: 34\r\n
+  s>     \r\n
+  s>     debugreflect service not available
+
+Restart server to get debugreflect endpoint
+
+  $ killdaemons.py
+  $ cat > server/.hg/hgrc << EOF
+  > [experimental]
+  > web.apiserver = true
+  > web.api.debugreflect = true
+  > web.api.http-v2 = true
+  > [web]
+  > push_ssl = false
+  > allow-push = *
+  > EOF
+
+  $ hg -R server serve -p $HGPORT -d --pid-file hg.pid -E error.log
+  $ cat hg.pid > $DAEMON_PIDS
+
+Single command frame is decoded
+
+  $ send << EOF
+  > httprequest POST api/$HTTPV2/ro/debugreflect
+  >     accept: $MEDIATYPE
+  >     content-type: $MEDIATYPE
+  >     user-agent: test
+  >     frame command-name eos command1
+  > EOF
+  using raw connection to peer
+  s>     POST /api/exp-http-v2-0001/ro/debugreflect HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0001\r\n
+  s>     content-type: application/mercurial-exp-framing-0001\r\n
+  s>     user-agent: test\r\n
+  s>     content-length: 12\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     \r\n
+  s>     \x08\x00\x00\x11command1
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: text/plain\r\n
+  s>     Content-Length: 117\r\n
+  s>     \r\n
+  s>     received: 1 1 command1\n
+  s>     {"action": "runcommand", "args": {}, "command": "command1", "data": null}\n
+  s>     received: <no frame>
+
+Single argument frame is received
+
+  $ send << EOF
+  > httprequest POST api/$HTTPV2/ro/debugreflect
+  >     accept: $MEDIATYPE
+  >     content-type: $MEDIATYPE
+  >     user-agent: test
+  >     frame command-name have-args command1
+  >     frame command-argument eoa \x03\x00\x05\x00foovalue
+  > EOF
+  using raw connection to peer
+  s>     POST /api/exp-http-v2-0001/ro/debugreflect HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0001\r\n
+  s>     content-type: application/mercurial-exp-framing-0001\r\n
+  s>     user-agent: test\r\n
+  s>     content-length: 28\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     \r\n
+  s>     \x08\x00\x00\x12command1\x0c\x00\x00"\x03\x00\x05\x00foovalue
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: text/plain\r\n
+  s>     Content-Length: 217\r\n
+  s>     \r\n
+  s>     received: 1 2 command1\n
+  s>     {"action": "wantframe", "state": "command-receiving-args"}\n
+  s>     received: 2 2 \x03\x00\x05\x00foovalue\n
+  s>     {"action": "runcommand", "args": {"foo": "value"}, "command": "command1", "data": null}\n
+  s>     received: <no frame>
+
+Multiple argument frames are received
+
+  $ send << EOF
+  > httprequest POST api/$HTTPV2/ro/debugreflect
+  >     accept: $MEDIATYPE
+  >     content-type: $MEDIATYPE
+  >     user-agent: test
+  >     frame command-name have-args command1
+  >     frame command-argument 0 \x03\x00\x05\x00foovalue
+  >     frame command-argument eoa \x04\x00\x04\x00foo1val1
+  > EOF
+  using raw connection to peer
+  s>     POST /api/exp-http-v2-0001/ro/debugreflect HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0001\r\n
+  s>     content-type: application/mercurial-exp-framing-0001\r\n
+  s>     user-agent: test\r\n
+  s>     content-length: 44\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     \r\n
+  s>     \x08\x00\x00\x12command1\x0c\x00\x00 \x03\x00\x05\x00foovalue\x0c\x00\x00"\x04\x00\x04\x00foo1val1
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: text/plain\r\n
+  s>     Content-Length: 319\r\n
+  s>     \r\n
+  s>     received: 1 2 command1\n
+  s>     {"action": "wantframe", "state": "command-receiving-args"}\n
+  s>     received: 2 0 \x03\x00\x05\x00foovalue\n
+  s>     {"action": "wantframe", "state": "command-receiving-args"}\n
+  s>     received: 2 2 \x04\x00\x04\x00foo1val1\n
+  s>     {"action": "runcommand", "args": {"foo": "value", "foo1": "val1"}, "command": "command1", "data": null}\n
+  s>     received: <no frame>
+
+Command with single data frame
+
+  $ send << EOF
+  > httprequest POST api/$HTTPV2/ro/debugreflect
+  >     accept: $MEDIATYPE
+  >     content-type: $MEDIATYPE
+  >     user-agent: test
+  >     frame command-name have-data command1
+  >     frame command-data eos data!
+  > EOF
+  using raw connection to peer
+  s>     POST /api/exp-http-v2-0001/ro/debugreflect HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0001\r\n
+  s>     content-type: application/mercurial-exp-framing-0001\r\n
+  s>     user-agent: test\r\n
+  s>     content-length: 21\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     \r\n
+  s>     \x08\x00\x00\x14command1\x05\x00\x002data!
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: text/plain\r\n
+  s>     Content-Length: 199\r\n
+  s>     \r\n
+  s>     received: 1 4 command1\n
+  s>     {"action": "wantframe", "state": "command-receiving-data"}\n
+  s>     received: 3 2 data!\n
+  s>     {"action": "runcommand", "args": {}, "command": "command1", "data": "data!"}\n
+  s>     received: <no frame>
+
+Command with multiple data frames
+
+  $ send << EOF
+  > httprequest POST api/$HTTPV2/ro/debugreflect
+  >     accept: $MEDIATYPE
+  >     content-type: $MEDIATYPE
+  >     user-agent: test
+  >     frame command-name have-data command1
+  >     frame command-data continuation data1
+  >     frame command-data continuation data2
+  >     frame command-data eos data3
+  > EOF
+  using raw connection to peer
+  s>     POST /api/exp-http-v2-0001/ro/debugreflect HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0001\r\n
+  s>     content-type: application/mercurial-exp-framing-0001\r\n
+  s>     user-agent: test\r\n
+  s>     content-length: 39\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     \r\n
+  s>     \x08\x00\x00\x14command1\x05\x00\x001data1\x05\x00\x001data2\x05\x00\x002data3
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: text/plain\r\n
+  s>     Content-Length: 367\r\n
+  s>     \r\n
+  s>     received: 1 4 command1\n
+  s>     {"action": "wantframe", "state": "command-receiving-data"}\n
+  s>     received: 3 1 data1\n
+  s>     {"action": "wantframe", "state": "command-receiving-data"}\n
+  s>     received: 3 1 data2\n
+  s>     {"action": "wantframe", "state": "command-receiving-data"}\n
+  s>     received: 3 2 data3\n
+  s>     {"action": "runcommand", "args": {}, "command": "command1", "data": "data1data2data3"}\n
+  s>     received: <no frame>
+
+Unexpected frame type
+
+  $ send << EOF
+  > httprequest POST api/$HTTPV2/ro/debugreflect
+  >     accept: $MEDIATYPE
+  >     content-type: $MEDIATYPE
+  >     user-agent: test
+  >     frame command-argument 0 0
+  >     frame command-name eos mycommand
+  > EOF
+  using raw connection to peer
+  s>     POST /api/exp-http-v2-0001/ro/debugreflect HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0001\r\n
+  s>     content-type: application/mercurial-exp-framing-0001\r\n
+  s>     user-agent: test\r\n
+  s>     content-length: 18\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     \r\n
+  s>     \x01\x00\x00 0	\x00\x00\x11mycommand
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: text/plain\r\n
+  s>     Content-Length: 181\r\n
+  s>     \r\n
+  s>     received: 2 0 0\n
+  s>     {"action": "error", "message": "expected command frame; got 2"}\n
+  s>     received: 1 1 mycommand\n
+  s>     {"action": "error", "message": "server already errored"}\n
+  s>     received: <no frame>
+
+Missing flags on command name frame
+
+  $ send << EOF
+  > httprequest POST api/$HTTPV2/ro/debugreflect
+  >     accept: $MEDIATYPE
+  >     content-type: $MEDIATYPE
+  >     user-agent: test
+  >     frame command-name 0 command1
+  > EOF
+  using raw connection to peer
+  s>     POST /api/exp-http-v2-0001/ro/debugreflect HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0001\r\n
+  s>     content-type: application/mercurial-exp-framing-0001\r\n
+  s>     user-agent: test\r\n
+  s>     content-length: 12\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     \r\n
+  s>     \x08\x00\x00\x10command1
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: text/plain\r\n
+  s>     Content-Length: 114\r\n
+  s>     \r\n
+  s>     received: 1 0 command1\n
+  s>     {"action": "error", "message": "missing frame flags on command frame"}\n
+  s>     received: <no frame>
+
+Missing argument frame
+
+  $ send << EOF
+  > httprequest POST api/$HTTPV2/ro/debugreflect
+  >     accept: $MEDIATYPE
+  >     content-type: $MEDIATYPE
+  >     user-agent: test
+  >     frame command-name have-args command1
+  >     frame command-name 0 ignored
+  > EOF
+  using raw connection to peer
+  s>     POST /api/exp-http-v2-0001/ro/debugreflect HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0001\r\n
+  s>     content-type: application/mercurial-exp-framing-0001\r\n
+  s>     user-agent: test\r\n
+  s>     content-length: 23\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     \r\n
+  s>     \x08\x00\x00\x12command1\x07\x00\x00\x10ignored
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: text/plain\r\n
+  s>     Content-Length: 197\r\n
+  s>     \r\n
+  s>     received: 1 2 command1\n
+  s>     {"action": "wantframe", "state": "command-receiving-args"}\n
+  s>     received: 1 0 ignored\n
+  s>     {"action": "error", "message": "expected command argument frame; got 1"}\n
+  s>     received: <no frame>
+
+Argument frame with incomplete name
+
+  $ send << EOF
+  > httprequest POST api/$HTTPV2/ro/debugreflect
+  >     accept: $MEDIATYPE
+  >     content-type: $MEDIATYPE
+  >     user-agent: test
+  >     frame command-name have-args command1
+  >     frame command-argument eoa \x04\x00\xde\xadfoo
+  > EOF
+  using raw connection to peer
+  s>     POST /api/exp-http-v2-0001/ro/debugreflect HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0001\r\n
+  s>     content-type: application/mercurial-exp-framing-0001\r\n
+  s>     user-agent: test\r\n
+  s>     content-length: 23\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     \r\n
+  s>     \x08\x00\x00\x12command1\x07\x00\x00"\x04\x00\xde\xadfoo
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: text/plain\r\n
+  s>     Content-Length: 206\r\n
+  s>     \r\n
+  s>     received: 1 2 command1\n
+  s>     {"action": "wantframe", "state": "command-receiving-args"}\n
+  s>     received: 2 2 \x04\x00\xde\xadfoo\n
+  s>     {"action": "error", "message": "malformed argument frame: partial argument name"}\n
+  s>     received: <no frame>
+
+Argument frame with incomplete value
+
+  $ send << EOF
+  > httprequest POST api/$HTTPV2/ro/debugreflect
+  >     accept: $MEDIATYPE
+  >     content-type: $MEDIATYPE
+  >     user-agent: test
+  >     frame command-name have-args command1
+  >     frame command-argument eoa \x03\x00\xaa\xaafoopartialvalue
+  > EOF
+  using raw connection to peer
+  s>     POST /api/exp-http-v2-0001/ro/debugreflect HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0001\r\n
+  s>     content-type: application/mercurial-exp-framing-0001\r\n
+  s>     user-agent: test\r\n
+  s>     content-length: 35\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     \r\n
+  s>     \x08\x00\x00\x12command1\x13\x00\x00"\x03\x00\xaa\xaafoopartialvalue
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: text/plain\r\n
+  s>     Content-Length: 219\r\n
+  s>     \r\n
+  s>     received: 1 2 command1\n
+  s>     {"action": "wantframe", "state": "command-receiving-args"}\n
+  s>     received: 2 2 \x03\x00\xaa\xaafoopartialvalue\n
+  s>     {"action": "error", "message": "malformed argument frame: partial argument value"}\n
+  s>     received: <no frame>
+
+Missing command data frame
+
+  $ send << EOF
+  > httprequest POST api/$HTTPV2/ro/debugreflect
+  >     accept: $MEDIATYPE
+  >     content-type: $MEDIATYPE
+  >     user-agent: test
+  >     frame command-name have-data command1
+  >     frame command-name eos command2
+  > EOF
+  using raw connection to peer
+  s>     POST /api/exp-http-v2-0001/ro/debugreflect HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0001\r\n
+  s>     content-type: application/mercurial-exp-framing-0001\r\n
+  s>     user-agent: test\r\n
+  s>     content-length: 24\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     \r\n
+  s>     \x08\x00\x00\x14command1\x08\x00\x00\x11command2
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: text/plain\r\n
+  s>     Content-Length: 194\r\n
+  s>     \r\n
+  s>     received: 1 4 command1\n
+  s>     {"action": "wantframe", "state": "command-receiving-data"}\n
+  s>     received: 1 1 command2\n
+  s>     {"action": "error", "message": "expected command data frame; got 1"}\n
+  s>     received: <no frame>
+
+No flags on command data frame
+
+  $ send << EOF
+  > httprequest POST api/$HTTPV2/ro/debugreflect
+  >     accept: $MEDIATYPE
+  >     content-type: $MEDIATYPE
+  >     user-agent: test
+  >     frame command-name have-data command1
+  >     frame command-data 0 data
+  > EOF
+  using raw connection to peer
+  s>     POST /api/exp-http-v2-0001/ro/debugreflect HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0001\r\n
+  s>     content-type: application/mercurial-exp-framing-0001\r\n
+  s>     user-agent: test\r\n
+  s>     content-length: 20\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     \r\n
+  s>     \x08\x00\x00\x14command1\x04\x00\x000data
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: text/plain\r\n
+  s>     Content-Length: 188\r\n
+  s>     \r\n
+  s>     received: 1 4 command1\n
+  s>     {"action": "wantframe", "state": "command-receiving-data"}\n
+  s>     received: 3 0 data\n
+  s>     {"action": "error", "message": "command data frame without flags"}\n
+  s>     received: <no frame>
+
+  $ cat error.log
diff --git a/mercurial/wireprotoserver.py b/mercurial/wireprotoserver.py
--- a/mercurial/wireprotoserver.py
+++ b/mercurial/wireprotoserver.py
@@ -19,6 +19,7 @@ 
     pycompat,
     util,
     wireproto,
+    wireprotoframing,
     wireprototypes,
 )
 
@@ -319,6 +320,11 @@ 
         res.setbodybytes('permission denied')
         return
 
+    # We have a special endpoint to reflect the request back at the client.
+    if command == b'debugreflect':
+        _processhttpv2reflectrequest(rctx.repo.ui, rctx.repo, req, res)
+        return
+
     if command not in wireproto.commands:
         res.status = b'404 Not Found'
         res.headers[b'Content-Type'] = b'text/plain'
@@ -343,8 +349,7 @@ 
                            % FRAMINGTYPE)
         return
 
-    if (b'Content-Type' in req.headers
-        and req.headers[b'Content-Type'] != FRAMINGTYPE):
+    if req.headers.get(b'Content-Type') != FRAMINGTYPE:
         res.status = b'415 Unsupported Media Type'
         # TODO we should send a response with appropriate media type,
         # since client does Accept it.
@@ -358,6 +363,48 @@ 
     res.headers[b'Content-Type'] = b'text/plain'
     res.setbodybytes(b'/'.join(urlparts) + b'\n')
 
+def _processhttpv2reflectrequest(ui, repo, req, res):
+    """Reads unified frame protocol request and dumps out state to client.
+
+    This special endpoint can be used to help debug the wire protocol.
+
+    Instead of routing the request through the normal dispatch mechanism,
+    we instead read all frames, decode them, and feed them into our state
+    tracker. We then dump the log of all that activity back out to the
+    client.
+    """
+    import json
+
+    # Reflection APIs have a history of being abused, accidentally disclosing
+    # sensitive data, etc. So we have a config knob.
+    if not ui.configbool('experimental', 'web.api.debugreflect'):
+        res.status = b'404 Not Found'
+        res.headers[b'Content-Type'] = b'text/plain'
+        res.setbodybytes(_('debugreflect service not available'))
+        return
+
+    # We assume we have a unified framing protocol request body.
+
+    reactor = wireprotoframing.serverreactor(ui, repo)
+    states = []
+
+    while True:
+        frame = wireprotoframing.readframe(req.bodyfh)
+
+        if not frame:
+            states.append(b'received: <no frame>')
+            break
+
+        frametype, frameflags, payload = frame
+        states.append(b'received: %d %d %s' % (frametype, frameflags, payload))
+
+        r = reactor.onframerecv(frametype, frameflags, payload)
+        states.append(json.dumps(r, sort_keys=True, separators=(', ', ': ')))
+
+    res.status = b'200 OK'
+    res.headers[b'Content-Type'] = b'text/plain'
+    res.setbodybytes(b'\n'.join(states))
+
 # Maps API name to metadata so custom API can be registered.
 API_HANDLERS = {
     HTTPV2: {
diff --git a/mercurial/wireprotoframing.py b/mercurial/wireprotoframing.py
--- a/mercurial/wireprotoframing.py
+++ b/mercurial/wireprotoframing.py
@@ -13,6 +13,12 @@ 
 
 import struct
 
+from .i18n import _
+from . import (
+    error,
+    util,
+)
+
 FRAME_HEADER_SIZE = 4
 
 FRAME_TYPE_COMMAND_NAME = 0x01
@@ -72,6 +78,41 @@ 
 
     return frame
 
+def readframe(fh):
+    """Read a unified framing protocol frame from a file object.
+
+    Returns a 3-tuple of (type, flags, payload) for the decoded frame or
+    None if no frame is available. May raise if a malformed frame is
+    seen.
+    """
+    header = bytearray(FRAME_HEADER_SIZE)
+
+    readcount = fh.readinto(header)
+
+    if readcount == 0:
+        return None
+
+    if readcount != FRAME_HEADER_SIZE:
+        raise error.Abort(_('received incomplete frame: got %d bytes: %s') %
+                          (readcount, header))
+
+    # 24 bits payload length (little endian)
+    # 4 bits frame type
+    # 4 bits frame flags
+    # ... payload
+    framelength = header[0] + 256 * header[1] + 16384 * header[2]
+    typeflags = header[3]
+
+    frametype = (typeflags & 0xf0) >> 4
+    frameflags = typeflags & 0x0f
+
+    payload = fh.read(framelength)
+    if len(payload) != framelength:
+        raise error.Abort(_('frame length error: expected %d; got %d') %
+                          (framelength, len(payload)))
+
+    return frametype, frameflags, payload
+
 def createcommandframes(cmd, args, datafh=None):
     """Create frames necessary to transmit a request to run a command.
 
@@ -120,3 +161,178 @@ 
 
             if done:
                 break
+
+class serverreactor(object):
+    """Holds state of a server handling frame-based protocol requests.
+
+    This class is the "brain" of the unified frame-based protocol server
+    component. While the protocol is stateless from the perspective of
+    requests/commands, something needs to track which frames have been
+    received, what frames to expect, etc. This class is that thing.
+
+    Instances are modeled as a state machine of sorts. Instances are also
+    reactionary to external events. The point of this class is to encapsulate
+    the state of the connection and the exchange of frames, not to perform
+    work. Instead, callers tell this class when something occurs, like a
+    frame arriving. If that activity is worthy of a follow-up action (say
+    *run a command*), the return value of that handler will say so.
+
+    I/O and CPU intensive operations are purposefully delegated outside of
+    this class.
+    """
+
+    def __init__(self, ui, repo):
+        self._ui = ui
+        self._repo = repo
+        self._state = 'idle'
+        self._activecommand = None
+        self._activeargs = None
+        self._activedata = None
+        self._expectingargs = None
+        self._expectingdata = None
+        self._activeargname = None
+        self._activeargchunks = None
+
+    def onframerecv(self, frametype, frameflags, payload):
+        """Process a frame that has been received off the wire.
+
+        Returns a dict with an ``action`` key that details what action,
+        if any, the consumer should take next.
+        """
+        handlers = {
+            'idle': self._onframeidle,
+            'command-receiving-args': self._onframereceivingargs,
+            'command-receiving-data': self._onframereceivingdata,
+            'errored': self._onframeerrored,
+        }
+
+        meth = handlers.get(self._state)
+        if not meth:
+            raise error.ProgrammingError('unhandled state: %s' % self._state)
+
+        return meth(frametype, frameflags, payload)
+
+    def _makeerrorresult(self, msg):
+        return {
+            'action': 'error',
+            'message': msg,
+        }
+
+    def _makeruncommandresult(self):
+        return {
+            'action': 'runcommand',
+            'command': self._activecommand,
+            'args': self._activeargs,
+            'data': self._activedata.getvalue() if self._activedata else None,
+        }
+
+    def _makewantframeresult(self):
+        return {
+            'action': 'wantframe',
+            'state': self._state,
+        }
+
+    def _onframeidle(self, frametype, frameflags, payload):
+        # The only frame type that should be received in this state is a
+        # command request.
+        if frametype != FRAME_TYPE_COMMAND_NAME:
+            self._state = 'errored'
+            return self._makeerrorresult(
+                _('expected command frame; got %d') % frametype)
+
+        self._activecommand = payload
+        self._activeargs = {}
+        self._activedata = None
+
+        if frameflags & FLAG_COMMAND_NAME_EOS:
+            return self._makeruncommandresult()
+
+        self._expectingargs = bool(frameflags & FLAG_COMMAND_NAME_HAVE_ARGS)
+        self._expectingdata = bool(frameflags & FLAG_COMMAND_NAME_HAVE_DATA)
+
+        if self._expectingargs:
+            self._state = 'command-receiving-args'
+            return self._makewantframeresult()
+        elif self._expectingdata:
+            self._activedata = util.stringio()
+            self._state = 'command-receiving-data'
+            return self._makewantframeresult()
+        else:
+            self._state = 'errored'
+            return self._makeerrorresult(_('missing frame flags on '
+                                           'command frame'))
+
+    def _onframereceivingargs(self, frametype, frameflags, payload):
+        if frametype != FRAME_TYPE_COMMAND_ARGUMENT:
+            self._state = 'errored'
+            return self._makeerrorresult(_('expected command argument '
+                                           'frame; got %d') % frametype)
+
+        offset = 0
+        namesize, valuesize = ARGUMENT_FRAME_HEADER.unpack_from(payload)
+        offset += ARGUMENT_FRAME_HEADER.size
+
+        # The argument name MUST fit inside the frame.
+        argname = payload[offset:offset + namesize]
+        offset += namesize
+
+        if len(argname) != namesize:
+            self._state = 'errored'
+            return self._makeerrorresult(_('malformed argument frame: '
+                                           'partial argument name'))
+
+        argvalue = payload[offset:]
+
+        # Argument value spans multiple frames. Record our active state
+        # and wait for the next frame.
+        if frameflags & FLAG_COMMAND_ARGUMENT_CONTINUATION:
+            raise error.ProgrammingError('not yet implemented')
+            self._activeargname = argname
+            self._activeargchunks = [argvalue]
+            self._state = 'command-arg-continuation'
+            return self._makewantframeresult()
+
+        # Common case: the argument value is completely contained in this
+        # frame.
+
+        if len(argvalue) != valuesize:
+            self._state = 'errored'
+            return self._makeerrorresult(_('malformed argument frame: '
+                                           'partial argument value'))
+
+        self._activeargs[argname] = argvalue
+
+        if frameflags & FLAG_COMMAND_ARGUMENT_EOA:
+            if self._expectingdata:
+                self._state = 'command-receiving-data'
+                # TODO signal request to run a command once we don't
+                # buffer data frames.
+                return self._makewantframeresult()
+            else:
+                self._state = 'waiting'
+                return self._makeruncommandresult()
+        else:
+            return self._makewantframeresult()
+
+    def _onframereceivingdata(self, frametype, frameflags, payload):
+        if frametype != FRAME_TYPE_COMMAND_DATA:
+            self._state = 'errored'
+            return self._makeerrorresult(_('expected command data frame; '
+                                           'got %d') % frametype)
+
+        # TODO support streaming data instead of buffering it.
+        self._activedata.write(payload)
+
+        if frameflags & FLAG_COMMAND_DATA_CONTINUATION:
+            return self._makewantframeresult()
+        elif frameflags & FLAG_COMMAND_DATA_EOS:
+            self._activedata.seek(0)
+            self._state = 'idle'
+            return self._makeruncommandresult()
+        else:
+            self._state = 'errored'
+            return self._makeerrorresult(_('command data frame without '
+                                           'flags'))
+
+    def _onframeerrored(self, frametype, frameflags, payload):
+        return self._makeerrorresult(_('server already errored'))
diff --git a/mercurial/util.py b/mercurial/util.py
--- a/mercurial/util.py
+++ b/mercurial/util.py
@@ -2559,6 +2559,14 @@ 
 
         return data
 
+    def readinto(self, b):
+        res = self.read(len(b))
+        if res is None:
+            return None
+
+        b[0:len(res)] = res
+        return len(res)
+
 def stringmatcher(pattern, casesensitive=True):
     """
     accepts a string, possibly starting with 're:' or 'literal:' prefix.
diff --git a/mercurial/configitems.py b/mercurial/configitems.py
--- a/mercurial/configitems.py
+++ b/mercurial/configitems.py
@@ -583,6 +583,9 @@ 
 coreconfigitem('experimental', 'web.api.http-v2',
     default=False,
 )
+coreconfigitem('experimental', 'web.api.debugreflect',
+    default=False,
+)
 coreconfigitem('experimental', 'xdiff',
     default=False,
 )