Arquivos
hhvm/hphp/doc/debugger.devdocs
Ryan Skidmore 078f8473b2 Fix typos (src)
Fix typos (src)

Closes #1329

Reviewed By: @edwinsmith

Differential Revision: D1089170

Pulled By: @scannell
2013-12-10 09:32:57 -08:00

601 linhas
29 KiB
Plaintext

This document is intended to help developers understand how HHVM
debugging is implemented. For user documentation, see
docs/debugger.start.
1. Overview
-----------
HHVM provides a rich set of debugging services as well as a
command-line debugger client. The client and server (VM) can be on the
same or different machines. The client and server may also be in the
same process when debugging a script instead of a web server.
For simplicity, much of this document will assume the client and
server are in different processes. The operation of the various
components below is mostly unchanged when they are in the same
process, though.
A HHVM server can be configured to allow remote debugging with option
Eval.Debugger.EnableDebuggerServer. This creates a new debug-only
endpoint to which debugger clients may connect on the port specified
by Eval.Debugger.DebuggerServerPort (default 8089). The class
DebuggerServer is responsible for setting up and listening for
connections on this endpoint.
1.1 The Proxy
-------------
When a debugger client connects to a VM, whether that VM is a remote
server or a local instance running a script, a "debugger proxy" is
created by the server. This proxy owns a connection, via a socket
wrapped within a Thrift buffer, to the client which is doing the
debugging. All interaction with the client is performed through this
proxy, and any time the VM does something the debugger might need to
know about it informs the proxy. The proxy is implemented in the
DebuggerProxy class.
The proxy has two important states: interrupted, or not
interrupted. When a proxy is interrupted it listens for commands from
the client and responds to them. When it is not interrupted, the proxy
does nothing and simply sits around waiting to be interrupted. A proxy
gets interrupted in one of two ways: interesting events from the VM,
or by a dedicated signal polling thread in response to a signal from
the client.
The proxy will listen for commands from the client, and create
instances of subclasses of DebuggerCommand to execute those
commands. A command may respond to the client with results, or it may
cause the proxy to allow the interrupted thread to run again.
1.2 The Client
--------------
Anyone can build a client so long as they speak the protocol described
below. HHVM provides a command line client, usually called
"hphpd". The client is invoked by passing "--mode debug" on the
command line. This causes HHVM to create a DebuggerClient object,
which creates a new thread which will run the command processing
loop. The client may attach to a server, or it may attach to the VM in
its own process and debug a script running on the main thread. If
there is no script to run, the main thread simply waits for the client
to finish.
Somewhat confusingly, the client also creates a proxy to represent the
VM in its own process. Thus, the proxy is not only a server-side
object. This proxy works just like the proxy on a server, and is
connected to in the same way. This proxy is created even if the client
is connecting to a server, though in that case it will not really be
used. If the user disconnects from their server this proxy will be
used to debug local scripts.
2.0 Communication Protocol
--------------------------
The communication protocol between the client and server is fairly
simple. It is based on Thrift, and the bulk of the implementation is
held in DebuggerCommand and its subclasses. All communication is based
on sending a Command, and in most cases receiving a response of the
same Command back, sometimes updated with more information. User
actions like print, where, and breakpoint translate into CmdPrint,
CmdWhere, and CmdBreak being sent to the server, and received back
with data like the result of the print or where operation, or status
about the breakpoints being set. Some commands cause the server to
resume execution of the program. User actions like continue, next, and
step translate into CmdContinue, CmdNext, and CmdStep being sent to
the server, which does not respond immediately but continues execution
until the program reaches, say, a breakpoint. The server then responds
with CmdInterrupt(BreakPointReached) to signal that it is now in the
"interrupted state" and is ready to receive more commands.
2.1 Initialization
------------------
When a new connection is made to the debugger port a proxy is created
to own that connection. This proxy is held in a global map keyed by a
sandbox ID. The proxy starts a "dummy sandbox" so it can accept
commands when there is no active request, and it starts up a signal
thread to poll the client for "signals", i.e., Ctrl-C. The dummy
sandbox is always started, and should not be confused with a real
sandbox. It will never serve a request and is really just there to
provide a place to execute code and interact with the server when
there are no requests.
The proxy is now ready to use, and is not interrupted. The client,
after establishing the connection on the debugger port now waits for a
command from the proxy. Note that the proxy really doesn't have it's
own thread. From now on, it runs on whatever thread interrupts
it. That may be the dummy sandbox thread, or it may be a normal
request thread.
So long as the proxy is not interrupted, the signal thread will poll
the client once per second with CmdSignal. The client responds with
CmdSignal, updated with whether or not Ctrl-C was pressed. If it was,
the signal thread asks each thread registered with the proxy to
interrupt, then goes back to polling. If the proxy remains
un-interrupted on the next poll, the signal thread will ask the dummy
sandbox thread to interrupt.
The dummy sandbox creates a thread which first interrupts the proxy
with "session started", and then waits to see if it needs to respond
to a signal from the client. If there is a signal from the client, the
dummy sandbox thread simply loops and interrupts the proxy with
"session started" again, and waits again.
The proxy, having been interrupted with "session started" from the
dummy sandbox, sends a CmdInterrupt(SessionStarted) to the client. The
proxy is now interrupted, so it enters a loop listening for commands
from the client. It also blocks the signal thread from sending
CmdSignal to the client. The proxy will remain interrupted, processing
commands requested by the client, until one of those commands causes
the proxy to leave the interrupted state and let the thread which
interrupted it continue. In the case of SessionStarted, that lets the
dummy sandbox thread continue. In the case of more interesting
interrupts from the VM, on threads executing requests or other user
code, it lets those threads run.
When the client receives the SessionStarted interrupt after making the
initial connection, it sends CmdMachine to attach to the user's
sandbox. The proxy "attaches" to the sandbox by registering itself as
the proxy for that sandbox id in the global proxy map. It then signals
the dummy sandbox thread, responds with CmdMachine, and returns to the
un-interrupted state. The client again waits for a command from the
proxy. The dummy sandbox receives the signal, loops, and interrupts
the proxy again with "session started", which sends a second
CmdInterrupt with type SessionStarted to the client. At this point the
client has received CmdInterrupt(SessionStarted) and the proxy is
interrupted in the dummy sandbox. The initial connection is complete,
and the client can issue whatever commands it wishes.
Graphically, the initial connection protocol is:
Server threads:
DL -- Debugger Server Listening Thread
SP -- Signal Polling Thread
DS -- Dummy Sandbox Thread
RTx -- Request Threads
Client Server
------------- --------------------------------------------------
DL SP DS RTx
| Listen for
| connections
| |
Connect on ------> |
debugger port Create Proxy
| Create SP ----> |
| Create DS ------------------> |
| | | |
| | | |
| <----------------------- CmdSignal |
CmdSignal ----------------------> | |
| | | |
| <------------------------------------ CmdInterrupt(SS)
CmdMachine(attach) ---------------------------> |
| | | Switch sandbox
| | | Notify DS
| <------------------------------------ CmdMachine
| | | Loop due to notify
| <------------------------------------ CmdInterrupt(SS)
Ready to go | | |
| | | |
v v v v
2.2 Steady State
----------------
Once the client and server are connected, the most common flow is that
the client waits for a CmdInterrupt from the proxy while the
application runs. When a request thread hits a breakpoint, throws an
exception, etc. it will interrupt the proxy. The proxy may decide to
ignore the interrupt (perhaps it is not configured to care about
thrown exceptions, for instance), in which case the request thread
will keep running and the client will never know about the event. If
the proxy does decide to take the interrupt it will send CmdInterrupt
to the client, then wait for commands from the client. The client will
send commands and get responses from the proxy until it sends a
command that causes the proxy to let the interrupted thread continue
execution.
Signal polling continues so long as the proxy is not interrupted.
Client Server
------------- --------------------------------------------------
DL SP DS RTx
Listen for | | | |
commands | | | IP is at a
| | | | breakpoint.
| <----------------------------------------------- CmdInterrupt(BPR)
CmdWhere --------------------------------------------------> |
| <-------------------------------------------------- CmdWhere
| | | | |
CmdPrint --------------------------------------------------> |
| <-------------------------------------------------- CmdPrint
| | | | |
CmdContinue -----------------------------------------------> |
| | | | Continue request
| <----------------------- CmdSignal | |
CmdSignal ----------------------> | | |
| | | | |
v v v v v
2.3 Ctrl-C
----------
When the client wants to interrupt the server while it is executing
code, it responds to CmdSignal with a flag indicating it wants to
stop. In the command line client, pressing Ctrl-C will cause the next
response to CmdSignal to set the flag. The proxy's signal polling
thread will then ask all current request threads to interrupt. When
one does, it will send a CmdInterrupt to the client.
Client Server
------------- --------------------------------------------------
DL SP DS RTx
| <----------------------- CmdSignal | |
CmdSignal(stop) ----------------> | | |
| | Set flag on each | |
| | RTx thread to cause | |
| | it to interrupt | |
| | | | Interrupt flag seen.
| <----------------------------------------------- CmdInterrupt(BPR)
| | | | |
v v v v v
2.4 Quitting
------------
CmdQuit is just like any other command, except that after the proxy
responds it will remove itself from the global proxy map, close the
connection, turn off the signal polling and dummy sandbox threads, and
destroy itself. The same actions will occur if the connection with the
client is lost for any other reason, even if no CmdQuit was
received. An error reading from the socket, a closed socket, etc.
2.4.1 Cleaning the proxy
------------------------
There are many cases where the proxy will notice that a client has
terminated the connection. The easiest one is when a quit command is
received, but the client may exit for any number of reasons, and in
any state. At a minimum, the proxy's signal polling thread will, after
one second, notice that the connection has been dropped and initiate
cleanup. However, neither the signal polling thread nor the dummy
sandbox thread can completely perform the cleanup because they are
owned by the proxy, and destroying the proxy would destroy those
threads before they have completed.
Thus, proxy cleanup may be initiated by any thread with Proxy::stop(),
but the final cleanup is performed by another thread doing
housekeeping work. The cleanup work waits for both the signal polling
and dummy sandbox threads to exit before completing. Server-side this
housekeeping work is done by the server thread, which is also
listening for new debugger connections. Note that this cleanup work
may complete while a request thread is still using the proxy. The last
reference to the proxy will finally destroy it, and the cleanup work
ensures that the proxy is still usable (and communicates that it is
stopped) by any outstanding request threads.
3.0 Client Implementation
-------------------------
The debugger client provided by HHVM is not a separate program, but a
special mode passed when executing HHVM. When "--mode debug" is
passed, a DebuggerClient object is created and a new thread is started
to execute DebuggerClient::run(). This "client thread" will execute
the main loop of the client, presenting a command prompt at times, and
running a communication loop with the server at other times.
A "local proxy" is also created, which is a normal DebuggerProxy for
the VM within the process. The client connects to this proxy normally,
with a socket and a thrift buffer. The proxy will create a signal
polling thread as usual, but it will not setup a dummy sandbox. The
lack of a dummy sandbox is really the only difference between a normal
proxy and this local proxy.
The main thread of the process will run a script specified on the
command line, just like HHVM normally would. The client will, by
default, attempt to debug that script. The main thread's execution is
slightly modified to allow the client to restart the script in
response to the 'run' command, and to give control back to the client
when the script is complete instead of exiting the process.
If the client is asked to connect to a remote server (either via "-h"
on the command line or via the 'machine connect' command) then it does
so as described above, and the main thread of the process will simply
idle and wait for the client to exit, at which time the process will
exit.
3.1 Console and communication loops
-----------------------------------
The debugger client has a top-level "event loop" which waits to
receive commands from the proxy to which it is attached. It responds
to CmdSignal, and when it receives a CmdInterrupt it enters a "console
loop" which presents a prompt to the user and processes user
commands. Each user command is recognized and an instance of a
subclass of DebuggerCommand is created, then executed.
The client will remain in the top-level event loop until an interrupt
is received, and it will remain in the console loop until a command
causes execution on the server to continue. When such a command is
executed (e.g. 'continue'), it sends the request to the server and
then throws DebuggerConsoleExitException. This is the notification to
exit the console loop and return to the event loop. The use of an
exception for this is a bit odd, as it is typically simply the last
thing done from the command's onClientImpl() method, which is called
directly from the console loop. The use of an exception here is
similar to the use of DebuggerClientExitException, discussed below,
but is now a vestige that will likely be removed soon.
Some commands can cause the client to exit, like 'quit'. The client
may also exit due to various error conditions, like loss of
communication with the server. In these cases a
DebuggerClientExitException is thrown. This causes execution to unwind
out of both the console and event loops, back to
DebuggerClient::run(), which eventually causes the client to exit. The
use of an exception here is more interesting as we will see below when
discussing nested event loops, as it allows the client to exit out of
multiple nested event loops with ease.
Somewhat confusingly, DebuggerClientExitException is also thrown by
the proxy when it detects the client is exiting. This signals the
termination of the request which was being debugged, which is
reasonable. But you'd imagine that a different exception could serve
that purpose. This is a subtle cheat in the system, and is more
meaningful when the proxy is local: it is a signal back to the
modified main thread that the debugger is quitting and the main thread
should now quit. In the local case, the main thread is much like a web
server request thread in that it calls into the proxy to interrupt it.
4.0 Nested execution
--------------------
Some commands allow a user to recursively execute more PHP code while
stopped at, say, a breakpoint. More breakpoints may be hit in the
newly executed function, and more code may be executed while stopped
at those breakpoints. The best example of this is CmdEval, which is
used to evaluate arbitrary functions and code (e.g., '@foo(42)').
Both the client and proxy are designed to handle this.
On the proxy, execution is paused waiting for a command from the
client. When an eval command is received, the proxy is put back into
the running state and the code is executed directly from the command
processing loop. If another interrupt is encountered, a new command
processing loop is entered and the interrupt is communicated to the
client just like normal. When then code completes, the response to the
eval command is sent and control is returned to the command processing
loop still on the stack. Thus we may recurse arbitrarily deep on the
server down multiple levels of proxy command loops, depending on how
deeply the user wishes to go. In practice this depth is quite shallow,
and most often involves no recursion.
On the client the story is much the same. When an eval command is
entered by the user, the client sends CmdEval to the proxy then enters
a nested event loop to wait for either the eval command to be sent
back, indication completion of the command, or for new CmdInterrupts
to be received, indicating breakpoints and other interesting events
while the code was being executed. Interrupts are handled normally,
and a new console loop is entered. Again, like the proxy these loops
may nest to arbitrary depths.
5.0 Control Flow
----------------
This section will discuss how the control flow commands 'next',
'step', 'out', and 'continue' work in the proxy and the VM. Operation
of these commands on the client isn't very interesting and is covered
well enough elsewhere.
Flow control commands are treated specially by the proxy. A single
instance of a subclass of CmdFlowControl is held in the m_flow member
variable of DebuggerProxy so long as it remains active, and having an
active flow command typically means that the proxy will deliver all
interrupts to the flow command for processing first. The flow command
will have the opportunity to examine the interrupt and determine if
the proxy should really stop at the interrupt, or continue
execution. A flow command will mark itself as completed when it
decides it's time to stop execution, and the proxy will remove it.
The only thing that can get the proxy to stop at an interrupt when a
flow command has said not to is an active breakpoint.
When the proxy does finally have an interrupt to stop at and send to
the client it removes and deletes the flow command. The flow command
is either complete, in which case it doesn't need to remain active
anyway, or it has been trumped by a breakpoint, in which case the flow
command is essentially forced to be complete.
A flow command is set as active on the proxy when it is received from
the client. Execution continues at that time.
5.1 Continue
------------
'continue' is the simplest control flow command. It simply marks
itself completed and returns. The proxy will remove the CmdContinue
and continue execution.
5.2 Step
--------
'step' is the next simplest flow command. It operates on the very
simple theory that to step to the next line executed you simply need
to interpret the program until the current source location
changes. This is, by definition, the next source line to be executed
no matter how execution flows in the program: function call,
exception, a simple add, etc.
First, CmdStep sets a "VM interrupt" flag which is eventually
installed on the VM's execution context for the current thread. The
interpreter's execution loop is instrumented, only when a debugger
client is actually attached to the VM, with a "hook" to the debugger
infrastructure called phpDebuggerOpcodeHook(). This hook is given the
PC of the opcode which is about to be executed. Setting the VM
interrupt flag ensures the VM will only interpret code, and thus call
the debugger opcode hook. This flag remains set only while the step
command is active; the VM will go back to executing translated code
once the command completes.
Next, CmdStep sets up a "location filter" for the current source
line. This is a very simple set which contains all PC's for bytecodes
implementing the current source line. It first consults the location
filter to determine if this is a location which might be interesting
to a debugger. If it gets a hit in the location filter, it simply
returns to the interpreter and executes the opcode as usual. The
location filter, then, is a simple mechanism which flow commands can
use to avoid being interrupted when a set of bytecodes is executed.
By setting up a location filter for the current source line and
turning on interrupts the step command ensures it will be interrupted
as soon as a bytecode not belonging to the current source line is
encountered. When that occurs it marks itself completed, and the proxy
will remove the CmdStep and destroy it. When any flow command is
destroyed the location filter is cleared, and the VM interrupt flag is
turned off.
5.3 Out
-------
'out' works very differently from 'step'. It predicts the next
execution location and sets up an "internal breakpoint" at that
location. This internal breakpoint works just like a normal breakpoint
set by a user, but it is not visible to the user. The breakpoint will
be automatically removed when CmdOut is destroyed. CmdOut sets this
breakpoint, then continues execution like normal (no location filter,
no VM interrupt flag).
The breakpoint is placed at the return address of the current
function. When it is reached, there are two possibilities: we have
returned from the function in question, in which case the command is
marked as complete, or we have hit the breakpoint during a recursive
call before exiting the original function.
To determine which case it is, CmdOut remembers the original stack
depth when the command was activated, and checks it when the
breakpoint is hit. If the stack depth is the same or lower, execution
is continued. Otherwise, the command is complete.
5.3.1 Determining the location to step out to
---------------------------------------------
Finding the location a function will return to is not straightforward
in all cases. For a function called from FCALL, the location is
obvious and unambiguous. But for functions called from, say, ITERNEXT
or a destructor called from a simple SETL the return offset stored in
the VM's activation record is not what we would expect. A complex
instruction may have multiple points at which execution could
continue. ITERNEXT, for instance, will branch to the top of the loop
if the iterator is not done, or fall through if the iterator is
done. Thus the step out location could be multiple places.
Also, functions with no source information are ignored for a step out
operation, so the location could be multiple frames up, not just one.
All of this is accounted for in CmdFlowControl::setupStepOuts(). See
that function for the details.
5.4 Next
--------
'next' is the most complex of the flow control commands, and builds on
the primitives of the others. It starts just like 'step' by trying to
use the interpreter to get off the current source line. But the goal
is not to just get off the line, it is to get to the next line. If
execution ends up deeper on the stack, a call has been made. CmdNext
will re-use the step out facility needed for 'out' and, in essence,
internally execute a step out to get back to the original source line,
then continue try to interpret off of it. If execution ends up
shallower on the stack, then we have stepped over a return and are
done as well.
There is extra logic to support stepping within continuations, so that
a 'next' executed on a source line with a 'yield' on it will step over
the yield. Thus CmdNext looks at the opcodes it is stepping over, and
upon recognizing CONTEXIT or CONTRETC will setup its own internal
breakpoints at destinations indicated by those opcodes.
For the details, see CmdNext.
5.5 Exceptions
--------------
Exceptions are non-local control flow that throw a monkey wrench into
most flow control commands. The VM is further instrumented, again only
while a debugger is attached, to call a hook whenever it is about to
transfer control to a catch block. This gives all of the flow commands
a chance to determine if the new location satisfies the completion
criteria for their respective operations. In most cases, the flow
command will force the first opcode of the catch clause to be
interpreted and let it's normal logic run its course.
6. Breakpoints
--------------
Breakpoints are set via commands processed by the client, which keeps a list
of all breakpoints. This list is sent to the proxy whenever an element is added,
modified or deleted. The proxy keeps of a copy of the list and updates each
breakpoint with a flag that indicates if the breakpoint is bound. An unbound
breakpoint is either invalid because it refers to a non existent source line
or function, or it refers to a location that has not yet been loaded into the
VM. The flag distinguishes these cases.
6.1 Checking if breakpoints are hit.
When a breakpoint becomes bound, it is mapped to a range of program counters
(PCs) in an execution unit. Each of these PCs is then added to a set of
breakable PCs (called the Breakpoint Filter) kept in the VM's execution context
object. If a debugger is attached to the VM, the interpreter calls
phpDebuggerOpcodeHook before executing each operation. This routine checks if
the PC of the operation is a member of the Breakpoint Filter. If so, it calls
the Debugger::InterruptVMHook method, which among other things, checks the
proxy's list of breakpoints to see if any of them apply to the source location
of the current PC. This involves a check if the breakpoint is enabled by the
user, a check if the source location is the same, a check if the breakpoint is
not already active and a check if the breakpoint is conditional on the value
of an expression.
6.2 Active breakpoints
A breakpoint can be already active when InterruptVMHook is called because it
can be set on a source line that maps to several interpreter operations.
(InterruptVMHook will be called for each such operation). It is not safe to
simply disable the breakpoint until the last of these operations are completed,
since one or more of those operations may be function calls that recurse back
to the active breakpoint.
In order to deal with this, each breakpoint keeps track of the depth of the
execution stack where it was activated. When InterruptVM finds that a breakpoint
will be hit, it checks that the height of the execution stack is greater than
the height recorded in the breakpoint before it proceeds with the steps that
are taken when a breakpoint is first hit. When control leaves the site of a
breakpoint at the right stack depth, the breakpoint is updated its previous
active stack depth (it has a stack of stack depths and it pops the top entry).
This pop operation cannot happen before the last operation corresponding to a
breakpoint has completed. However, it is not convenient for this to happen on
the very next operation. Instead, InterruptVM hook will update any breakpoints
that do not correspond to the current operation to ensure they are active if a
subsequent call to InterruptVMHook matches their location at the current stack
level.