Files
pybug/internal/bridge/client.go
2026-07-13 18:33:39 +02:00

501 lines
8.7 KiB
Go

package bridge
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"sync"
"sync/atomic"
)
var ErrNotRunning = errors.New("bridge not running")
var ErrAlreadyStarted = errors.New("bridge is already running")
type ExecutionPoint struct {
File string
Line int
}
type Bridge struct {
path string
lifecycleMu sync.Mutex
started bool
finished bool
closing bool
failure error
exitErr error
cmd *exec.Cmd
stdin io.WriteCloser
protocol io.ReadCloser
encoder *json.Encoder
decoder *json.Decoder
writeMu sync.Mutex
nextID atomic.Uint64
pendingMu sync.Mutex
pending map[uint64]chan Message
events chan Message
output chan Output
done chan struct{}
finishOnce sync.Once
}
type OutputStream string
const (
StdoutStream OutputStream = "stdout"
StderrStream OutputStream = "stderr"
)
type Output struct {
Stream OutputStream
Text string
}
func NewBridge(path string) *Bridge {
return &Bridge{
path: path,
pending: make(map[uint64]chan Message),
events: make(chan Message, 32),
output: make(chan Output, 128),
done: make(chan struct{}),
}
}
func (b *Bridge) Events() <-chan Message {
return b.events
}
func (b *Bridge) Output() <-chan Output {
return b.output
}
func (b *Bridge) Done() <-chan struct{} {
return b.done
}
func (b *Bridge) Start() error {
b.lifecycleMu.Lock()
defer b.lifecycleMu.Unlock()
if b.started {
return ErrAlreadyStarted
}
protocolReader, protocolWriter, err := os.Pipe()
if err != nil {
return fmt.Errorf("create protocol pipe: %w", err)
}
cmd := exec.Command(
"python",
"-u",
"python/pybug_runtime.py",
b.path,
)
cmd.ExtraFiles = []*os.File{protocolWriter}
cmd.Env = append(os.Environ(), "PYBUG_PROTOCOL_FD=3")
stdin, err := cmd.StdinPipe()
if err != nil {
protocolWriter.Close()
protocolReader.Close()
return fmt.Errorf("create command pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
stdin.Close()
protocolReader.Close()
protocolWriter.Close()
return fmt.Errorf("create stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
stdout.Close()
protocolReader.Close()
protocolWriter.Close()
return fmt.Errorf("create stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
stdin.Close()
stdout.Close()
stderr.Close()
protocolWriter.Close()
protocolReader.Close()
return fmt.Errorf("start debugger runtime: %w", err)
}
if err := protocolWriter.Close(); err != nil {
cmd.Process.Kill()
stdin.Close()
stdout.Close()
stderr.Close()
protocolWriter.Close()
protocolReader.Close()
return fmt.Errorf("close parent protocol writer: %w", err)
}
b.cmd = cmd
b.stdin = stdin
b.protocol = protocolReader
b.encoder = json.NewEncoder(stdin)
b.decoder = json.NewDecoder(protocolReader)
b.started = true
go b.readProtocol()
go b.readOutput(stdout, StdoutStream)
go b.readOutput(stderr, StderrStream)
go b.waitForProcess()
return nil
}
func (b *Bridge) ensureRunning() error {
b.lifecycleMu.Lock()
defer b.lifecycleMu.Unlock()
if !b.started || b.finished {
return ErrNotRunning
}
return nil
}
func (b *Bridge) writeMessage(msg Message) error {
b.writeMu.Lock()
defer b.writeMu.Unlock()
if err := b.encoder.Encode(msg); err != nil {
return fmt.Errorf("write protocol message: %w", err)
}
return nil
}
func (b *Bridge) request(
ctx context.Context,
name string,
body any,
) (Message, error) {
if err := b.ensureRunning(); err != nil {
return Message{}, err
}
id := b.nextID.Add(1)
response := make(chan Message, 1)
b.pendingMu.Lock()
b.pending[id] = response
b.pendingMu.Unlock()
defer func() {
b.pendingMu.Lock()
delete(b.pending, id)
b.pendingMu.Unlock()
}()
msg, err := newMessage(Request, &id, name, body)
if err != nil {
return Message{}, fmt.Errorf("create %q request: %w", name, err)
}
if err := msg.Validate(); err != nil {
return Message{}, fmt.Errorf("validate %q request: %w", name, err)
}
if err := b.writeMessage(msg); err != nil {
return Message{}, err
}
select {
case response := <-response:
if response.Name != name {
return Message{}, fmt.Errorf("response name mismatch: requested %q, received %q", name, response.Name)
}
if response.Error != nil {
return Message{}, response.Error
}
return response, nil
case <-ctx.Done():
return Message{}, ctx.Err()
case <-b.done:
return Message{}, ErrNotRunning
}
}
func (b *Bridge) deliverResponse(msg Message) {
id := *msg.RequestId
b.pendingMu.Lock()
response, ok := b.pending[id]
b.pendingMu.Unlock()
if !ok {
return
}
select {
case response <- msg:
case <-b.done:
default:
}
}
func (b *Bridge) readProtocol() {
for {
var msg Message
if err := b.decoder.Decode(&msg); err != nil {
if errors.Is(err, io.EOF) {
return
}
select {
case <-b.done:
return
default:
}
b.abort(fmt.Errorf("decode protocol message: %w", err))
return
}
if err := msg.Validate(); err != nil {
b.abort(fmt.Errorf("invalid protocol message: %w", err))
return
}
switch msg.Type {
case Response:
b.deliverResponse(msg)
case Event:
select {
case b.events <- msg:
case <-b.done:
return
}
default:
b.abort(fmt.Errorf("unexpected message type from runtime: %q", msg.Type))
return
}
}
}
func (b *Bridge) readOutput(reader io.ReadCloser, stream OutputStream) {
defer reader.Close()
buffer := make([]byte, 4096)
for {
n, err := reader.Read(buffer)
if n > 0 {
output := Output{
Stream: stream,
Text: string(buffer[:n]),
}
select {
case b.output <- output:
case <-b.done:
return
}
}
if err != nil {
if !errors.Is(err, io.EOF) {
b.abort(fmt.Errorf("read target %s: %w", stream, err))
}
return
}
}
}
func (b *Bridge) waitForProcess() {
err := b.cmd.Wait()
b.finish(err)
}
func (b *Bridge) abort(err error) {
b.lifecycleMu.Lock()
if b.failure == nil {
b.failure = err
}
var process *os.Process
if b.cmd != nil {
process = b.cmd.Process
}
finished := b.finished
b.lifecycleMu.Unlock()
if !finished && process != nil {
_ = process.Kill()
}
}
func (b *Bridge) finish(waitErr error) {
b.finishOnce.Do(func() {
b.lifecycleMu.Lock()
b.finished = true
if b.failure != nil {
b.exitErr = b.failure
} else if !b.closing {
b.exitErr = waitErr
}
stdin := b.stdin
protocol := b.protocol
b.lifecycleMu.Unlock()
if stdin != nil {
_ = stdin.Close()
}
if protocol != nil {
_ = protocol.Close()
}
close(b.done)
})
}
func (b *Bridge) Wait(ctx context.Context) error {
b.lifecycleMu.Lock()
started := b.started
b.lifecycleMu.Unlock()
if !started {
return ErrNotRunning
}
select {
case <-b.done:
return b.processError()
case <-ctx.Done():
return ctx.Err()
}
}
func (b *Bridge) Close(ctx context.Context) error {
b.lifecycleMu.Lock()
if !b.started {
b.lifecycleMu.Unlock()
return ErrNotRunning
}
if b.finished {
b.lifecycleMu.Unlock()
return b.processError()
}
b.closing = true
process := b.cmd.Process
b.lifecycleMu.Unlock()
if process != nil {
err := process.Kill()
if err != nil && !errors.Is(err, os.ErrProcessDone) {
return fmt.Errorf("kill debugger runtime: %w", err)
}
}
return b.Wait(ctx)
}
func (b *Bridge) processError() error {
b.lifecycleMu.Lock()
defer b.lifecycleMu.Unlock()
return b.exitErr
}
func (b *Bridge) Locals(ctx context.Context) (LocalsResponse, error) {
msg, err := b.request(ctx, "locals/get", nil)
if err != nil {
return LocalsResponse{}, err
}
var result LocalsResponse
if err := json.Unmarshal(msg.Body, &result); err != nil {
return LocalsResponse{}, fmt.Errorf("decode locals response: %w", err)
}
return result, nil
}
func (b *Bridge) Continue(ctx context.Context) error {
_, err := b.request(ctx, "execution/continue", nil)
return err
}
func (b *Bridge) StepInto(ctx context.Context) error {
_, err := b.request(ctx, "execution/step-into", nil)
return err
}
func (b *Bridge) StepOver(ctx context.Context) error {
_, err := b.request(ctx, "execution/step-over", nil)
return err
}
func (b *Bridge) SetBreakpoint(ctx context.Context, file string, line int) (SetBreakpointResponse, error) {
msg, err := b.request(
ctx,
"breakpoint/set",
SetBreakpointRequest{
File: file,
Line: line,
},
)
if err != nil {
return SetBreakpointResponse{}, err
}
var response SetBreakpointResponse
if err := json.Unmarshal(msg.Body, &response); err != nil {
return SetBreakpointResponse{}, fmt.Errorf(
"decode breakpoint response: %w", err,
)
}
return response, nil
}
func (b *Bridge) RemoveBreakpoint(
ctx context.Context,
file string,
line int,
) error {
_, err := b.request(
ctx,
"breakpoint/remove",
RemoveBreakpointRequest{
File: file,
Line: line,
},
)
return err
}