From 0032e63e1303fe81048a50ee9e2b758cba9204b8 Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Sat, 31 Jan 2026 16:06:11 +0000 Subject: [PATCH 01/36] docs: update Readme --- README.md | 2 +- cmd/bingo/main.go | 24 ++++++++++- internal/cli/cli.go | 102 -------------------------------------------- 3 files changed, 24 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 913725e..4caadb6 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,6 @@ BinGo is a standalone visual concurrency debugger for Go that makes goroutines, channels, and synchronization behavior easy to understand. It captures detailed runtime events and turns them into clear, interactive visualizations, whether you’re running it as a terminal UI or inside editors like VS Code or Vim. With features like goroutine lifecycle tracking, channel and mutex inspection, timeline replay, and deadlock or leak detection, BinGo helps developers see how their concurrent programs actually behave and debug tricky issues that traditional tools miss. Its modular design keeps the core engine UI-agnostic, so new frontends and integrations can be built easily by the community. - ## Documentation + For detailed documentation, including client meeting minutes, existing solution comparision, project roadmap, installation instructions, usage guides, and API references, please read the [**Docs**](https://github.com/bingosuite/bingo/tree/main/docs). diff --git a/cmd/bingo/main.go b/cmd/bingo/main.go index e4e4346..95fcd41 100644 --- a/cmd/bingo/main.go +++ b/cmd/bingo/main.go @@ -27,6 +27,8 @@ var ( interruptCode = []byte{0xCC} ) +const PTRACE_O_EXITKILL = 0x100000 // Option to kill the target process when Bingo exits + func main() { cfg, err := config.Load("config/config.yml") if err != nil { @@ -80,12 +82,32 @@ func run(target string) { fmt.Fprintf(os.Stderr, "Error getting PGID: %v\n", err) } fmt.Printf("Starting process with PID: %d and PGID: %d\n", pid, pgid) + + // Verify process is actually stopped and alive + process, err := os.FindProcess(pid) + if err != nil { + log.Printf("Failed to find process: %v", err) + panic(err) + } + //Enables thead tracking - if err := syscall.PtraceSetOptions(pid, syscall.PTRACE_O_TRACECLONE); err != nil { + if err := syscall.PtraceSetOptions(pid, syscall.PTRACE_O_TRACECLONE|PTRACE_O_EXITKILL); err != nil { log.Printf("Failed to enable Ptrace on clones: %v", err) panic(err) } + // Ensure we can detach on exit + defer func() { + if err := syscall.PtraceDetach(pid); err != nil { + log.Printf("Failed to detach from target: %v", err) + panic(err) + } + if err := process.Kill(); err != nil { + log.Printf("Failed to kill target: %v", err) + panic(err) + } + }() + cont := false cont, breakpointSet, originalCode, line = cli.Resume(pid, targetFile, line, breakpointSet, originalCode, setBreak) if cont { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 825263e..7f1e458 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1,103 +1 @@ package cli - -import ( - "bufio" - "fmt" - "os" - "strconv" - "strings" -) - -func Resume( - pid int, - targetFile string, - currentLine int, - breakpointSet bool, - originalCode []byte, - setBreak func(int, string, int) (bool, []byte), -) (bool, bool, []byte, int) { - sub := false - scanner := bufio.NewScanner(os.Stdin) - fmt.Printf("\n(C)ontinue, (S)tep, set (B)reakpoint or (Q)uit >") - for { - scanner.Scan() - input := scanner.Text() - switch strings.ToUpper(input) { - case "C": - return true, breakpointSet, originalCode, currentLine - case "S": - return false, breakpointSet, originalCode, currentLine - case "B": - fmt.Printf("\nEnter line number in %s: >", targetFile) - sub = true - case "Q": - os.Exit(0) - default: - if sub { - line, _ := strconv.Atoi(input) - breakpointSet, originalCode = setBreak(pid, targetFile, line) - return true, breakpointSet, originalCode, line - } - fmt.Printf("Unexpected input %s\n", input) - fmt.Printf("\n(C)ontinue, (S)tep, set (B)reakpoint or (Q)uit? > ") - } - } -} - -/*func outputStack(symTable *gosym.Table, pid int, ip uint64, sp uint64, bp uint64) { - - // ip = Instruction Pointer - // sp = Stack Pointer - // bp = Base(Frame) Pointer - - _, _, fn = symTable.PCToLine(ip) - var i uint64 - var nextbp uint64 - - for { - - // Only works if stack frame is [Return Address] - // [locals] - // [Saved RBP] - i = 0 - frameSize := bp - sp + 8 - - //Can happen when we look at bp and sp while they're being updated - if frameSize > 1000 || bp == 0 { - fmt.Printf("Weird frame size: SP: %X | BP: %X \n", sp, bp) - frameSize = 32 - bp = sp + frameSize - 8 - } - - // Read stack memory at sp into b - b := make([]byte, frameSize) - _, err := syscall.PtracePeekData(pid, uintptr(sp), b) - if err != nil { - panic(err) - } - - // Reads return address into content - content := binary.LittleEndian.Uint64((b[i : i+8])) - _, lineno, nextfn := symTable.PCToLine(content) - if nextfn != nil { - fn = nextfn - fmt.Printf(" called by %s line %d\n", fn.Name, lineno) - } - - //Rest of the frame - for i = 8; sp+1 <= bp; i += 8 { - content := binary.LittleEndian.Uint64(b[i : i+8]) - if sp+i == bp { - nextbp = content - } - } - - //Stop stack trace at main.main. If bp and sp are being updated we could miss main.main so we backstop with runtime.amin - if fn.Name == "main.main" || fn.Name == "runtime.main" { - break - } - - sp = sp + i - bp = nextbp - } -}*/ From 49be66b44e4eb8919e9bb2b843da2921d0fda3d9 Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Sat, 31 Jan 2026 17:58:58 +0000 Subject: [PATCH 02/36] chore: rewrite basic debugger feat: rewrite basic debugger and add proper bp handling docs: explain bp state machine --- cmd/bingo/main.go | 234 ++++++++++--------------------- internal/debugger/debugger.go | 31 ++++ internal/debuginfo/debug_info.go | 77 ++++++++++ 3 files changed, 181 insertions(+), 161 deletions(-) create mode 100644 internal/debugger/debugger.go create mode 100644 internal/debuginfo/debug_info.go diff --git a/cmd/bingo/main.go b/cmd/bingo/main.go index 95fcd41..19ed6d1 100644 --- a/cmd/bingo/main.go +++ b/cmd/bingo/main.go @@ -1,34 +1,22 @@ package main import ( - "debug/elf" - "debug/gosym" "fmt" "log" "os" "os/exec" "syscall" - "github.com/bingosuite/bingo/config" - "github.com/bingosuite/bingo/internal/cli" + config "github.com/bingosuite/bingo/config" + "github.com/bingosuite/bingo/internal/debugger" + debuginfo "github.com/bingosuite/bingo/internal/debuginfo" websocket "github.com/bingosuite/bingo/internal/ws" ) -var ( - targetFile string - line int - pc uint64 - fn *gosym.Func - symTable *gosym.Table - regs syscall.PtraceRegs - ws syscall.WaitStatus - originalCode []byte - breakpointSet bool - interruptCode = []byte{0xCC} +const ( + PTRACE_O_EXITKILL = 0x100000 // Set option to kill the target process when Bingo exits to true ) -const PTRACE_O_EXITKILL = 0x100000 // Option to kill the target process when Bingo exits - func main() { cfg, err := config.Load("config/config.yml") if err != nil { @@ -46,196 +34,120 @@ func main() { }() procName := os.Args[1] - path := "/workspaces/bingo/build/target/%s" - binLocation := fmt.Sprintf(path, procName) - - // Load Go symbol table from ELF - symTable = getSymbolTable(binLocation) - fn = symTable.LookupFunc("main.main") - targetFile, line, fn = symTable.PCToLine(fn.Entry) - run(binLocation) - -} - -func run(target string) { - var filename string + binLocation := fmt.Sprintf("/workspaces/bingo/build/target/%s", procName) - cmd := exec.Command(target) + // Set up target for execution + cmd := exec.Command(binLocation) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.SysProcAttr = &syscall.SysProcAttr{Ptrace: true} - // Start the target if err := cmd.Start(); err != nil { - fmt.Fprintf(os.Stderr, "Error starting process: %v\n", err) + handleError("Failed to start target: %v", err) } - - if err := cmd.Wait(); err != nil { // Will catch the SIGTRAP generated from starting a new process - fmt.Fprintf(os.Stderr, "Wait returned: %v\n", err) + if err := cmd.Wait(); err != nil { + log.Printf("Received SIGTRAP from process creation: %v", err) } - pid := cmd.Process.Pid - // Need this to wait on threads - pgid, err := syscall.Getpgid(pid) + db, err := debuginfo.NewDebugInfo(binLocation, cmd.Process.Pid) if err != nil { - fmt.Fprintf(os.Stderr, "Error getting PGID: %v\n", err) + log.Printf("Failed to create debug info: %v", err) + panic(err) } - fmt.Printf("Starting process with PID: %d and PGID: %d\n", pid, pgid) + log.Printf("Started process with PID: %d and PGID: %d\n", db.Target.PID, db.Target.PGID) - // Verify process is actually stopped and alive - process, err := os.FindProcess(pid) - if err != nil { - log.Printf("Failed to find process: %v", err) - panic(err) + // Enable tracking threads spawned from target and killing target once Bingo exits + if err := syscall.PtraceSetOptions(db.Target.PID, syscall.PTRACE_O_TRACECLONE|PTRACE_O_EXITKILL); err != nil { + handleError("Failed to set TRACECLONE and EXITKILL options on target: %v", err) } - //Enables thead tracking - if err := syscall.PtraceSetOptions(pid, syscall.PTRACE_O_TRACECLONE|PTRACE_O_EXITKILL); err != nil { - log.Printf("Failed to enable Ptrace on clones: %v", err) - panic(err) + pc, _, err := db.LineToPC(db.Target.Path, 11) + if err != nil { + handleError("Failed to get PC of line 11: %v", err) } - // Ensure we can detach on exit - defer func() { - if err := syscall.PtraceDetach(pid); err != nil { - log.Printf("Failed to detach from target: %v", err) - panic(err) - } - if err := process.Kill(); err != nil { - log.Printf("Failed to kill target: %v", err) - panic(err) - } - }() + if err := debugger.SetBreakpoint(db, pc); err != nil { + handleError("Failed to set breakpoint: %v", err) + } - cont := false - cont, breakpointSet, originalCode, line = cli.Resume(pid, targetFile, line, breakpointSet, originalCode, setBreak) - if cont { - if err := syscall.PtraceCont(pid, 0); err != nil { - log.Printf("Failed to continue execution after breakpoint: %v", err) - panic(err) - } - } else { - if err := syscall.PtraceSingleStep(pid); err != nil { - log.Printf("Failed to step after breakpoint: %v", err) - panic(err) - } + // Continue after the initial SIGTRAP, normally would ask the user what they want to do + if err := syscall.PtraceCont(db.Target.PID, 0); err != nil { + handleError("Failed to resume target execution: %v", err) } for { - // Wait until next breakpoint - wpid, err := syscall.Wait4(-1*pgid, &ws, 0, nil) + // Wait until any of the child processes of the target is interrupted or ends + var ws syscall.WaitStatus + wpid, err := syscall.Wait4(-1*db.Target.PGID, &ws, 0, nil) if err != nil { - log.Printf("Failed to wait for next breakpoint: %v", err) - panic(err) + handleError("Failed to wait for the target or any of its threads: %v", err) } if ws.Exited() { - if wpid == pid { + if wpid == db.Target.PID { // If target exited, terminate break } } else { - //Tracing only if stopped by breakpoint we set. Cloning child process creates trap so we want to ignore it + // Only stop on breakpoints caused by our debugger, ignore any other event like spawning of new threads if ws.StopSignal() == syscall.SIGTRAP && ws.TrapCause() != syscall.PTRACE_EVENT_CLONE { + + //TODO: import error handling and messages and pull logic out to debugger package + + // Read registers + var regs syscall.PtraceRegs if err := syscall.PtraceGetRegs(wpid, ®s); err != nil { - log.Printf("Failed to get registers: %v", err) - panic(err) + handleError("Failed to get registers: %v", err) } - filename, line, fn = symTable.PCToLine(regs.Rip) // TODO: chat says interrupt advances RIP by 1 so it should be -1, check if true + filename, line, fn := db.PCToLine(regs.Rip - 1) // Interrupt advances PC by 1 on x86, so we need to rewind fmt.Printf("Stopped at %s at %d in %s\n", fn.Name, line, filename) - //outputStack(symTable, wpid, regs.Rip, regs.Rsp, regs.Rbp) - if breakpointSet { - // TODO: chat says should step past breakpoint instead. normally: restore instruction, step, reinsert breakpoint - replaceCode(wpid, pc, originalCode) - breakpointSet = false + // Remove the breakpoint + bpAddr := regs.Rip - 1 + if err := debugger.ClearBreakpoint(db, bpAddr); err != nil { + handleError("Failed to clear breakpoint: %v", err) + } + regs.Rip = bpAddr + + // Rewind Rip by 1 + if err := syscall.PtraceSetRegs(wpid, ®s); err != nil { + handleError("Failed to restore registers: %v", err) + } + + // Step over the instruction we previously removed to put the breakpoint + if err := syscall.PtraceSingleStep(wpid); err != nil { + handleError("Failed to single-step: %v", err) + } + + // Wait until the program lets us know we stepped over (handle cases where we get another signal which Wait4 would consume) + var stepSig syscall.WaitStatus + if _, err := syscall.Wait4(wpid, &stepSig, 0, nil); err != nil { + handleError("Failed to wait for the single-step: %v", err) + } + // Put the breakpoint back + if err := debugger.SetBreakpoint(db, bpAddr); err != nil { + handleError("Failed to set breakpoint: %v", err) } - cont, breakpointSet, originalCode, line = cli.Resume(wpid, targetFile, line, breakpointSet, originalCode, setBreak) - if cont { - if err := syscall.PtraceCont(wpid, 0); err != nil { - log.Printf("Failed to continue after breakpoint: %v", err) - panic(err) - } - } else { - if err := syscall.PtraceSingleStep(wpid); err != nil { - log.Printf("Failed to step over after breakpoint: %v", err) - panic(err) - } + // Resume execution + if err := syscall.PtraceCont(wpid, 0); err != nil { + handleError("Failed to resume target execution: %v", err) } + } else { if err := syscall.PtraceCont(wpid, 0); err != nil { - log.Printf("Failed to continue after breakpoint: %v", err) - panic(err) + handleError("Failed to resume target execution: %v", err) } } } - } -} -func setBreak(pid int, filename string, line int) (bool, []byte) { - var err error - - // Map source (actual lines in the code) to the program counter - pc, _, err = symTable.LineToPC(filename, line) - if err != nil { - fmt.Printf("Can't find breakpoint for %s, %d\n", filename, line) - return false, []byte{} } - return true, replaceCode(pid, pc, interruptCode) -} - -func replaceCode(pid int, breakpoint uint64, code []byte) []byte { - og := make([]byte, len(code)) - _, err := syscall.PtracePeekData(pid, uintptr(breakpoint), og) // Save old data at breakpoint - if err != nil { - log.Printf("Failed to peek at instruction while setting breakpoint: %v", err) - panic(err) - } - _, err = syscall.PtracePokeData(pid, uintptr(breakpoint), code) // replace with interrupt code - if err != nil { - log.Printf("Failed to continue after breakpoint: %v", err) - panic(err) - } - return og } -func getSymbolTable(proc string) *gosym.Table { - - exe, err := elf.Open(proc) - if err != nil { - log.Printf("Failed to open ELF file: %v", err) - panic(err) - } - defer func() { - if err := exe.Close(); err != nil { - log.Printf("Failed to close ELF file: %v", err) - panic(err) - } - }() - - addr := exe.Section(".text").Addr - - lineTableData, err := exe.Section(".gopclntab").Data() - if err != nil { - log.Printf("Failed to get PC Line Table from ELF: %v", err) - panic(err) - } - lineTable := gosym.NewLineTable(lineTableData, addr) - - symTableData, err := exe.Section(".gosymtab").Data() - if err != nil { - log.Printf("Failed to get Symbol Table from ELF: %v", err) - panic(err) - } - - symTable, err := gosym.NewTable(symTableData, lineTable) - if err != nil { - log.Printf("Failed to create new Symbol Table: %v", err) - panic(err) - } +func handleError(msg string, err error) { + log.Printf(msg, err) + panic(err) - return symTable } diff --git a/internal/debugger/debugger.go b/internal/debugger/debugger.go new file mode 100644 index 0000000..89e20d4 --- /dev/null +++ b/internal/debugger/debugger.go @@ -0,0 +1,31 @@ +package debugger + +import ( + "fmt" + "syscall" + + "github.com/bingosuite/bingo/internal/debuginfo" +) + +var ( + bpCode = []byte{0xCC} +) + +func SetBreakpoint(d *debuginfo.DebugInfo, pc uint64) error { + original := make([]byte, len(bpCode)) + if _, err := syscall.PtracePeekData(d.Target.PID, uintptr(pc), original); err != nil { + return fmt.Errorf("failed to read original machine code into memory: %v", err) + } + if _, err := syscall.PtracePokeData(d.Target.PID, uintptr(pc), bpCode); err != nil { + return fmt.Errorf("failed to write breakpoint into memory: %v", err) + } + d.Breakpoints[pc] = original + return nil +} + +func ClearBreakpoint(d *debuginfo.DebugInfo, pc uint64) error { + if _, err := syscall.PtracePokeData(d.Target.PID, uintptr(pc), d.Breakpoints[pc]); err != nil { + return fmt.Errorf("failed to write breakpoint into memory: %v", err) + } + return nil +} diff --git a/internal/debuginfo/debug_info.go b/internal/debuginfo/debug_info.go new file mode 100644 index 0000000..c926b83 --- /dev/null +++ b/internal/debuginfo/debug_info.go @@ -0,0 +1,77 @@ +package debuginfo + +import ( + "debug/elf" + "debug/gosym" + "fmt" + "syscall" +) + +type Target struct { + Path string + PID int + PGID int +} + +type DebugInfo struct { + SymTable *gosym.Table + LineTable *gosym.LineTable + Breakpoints map[uint64][]byte + Target Target +} + +func NewDebugInfo(path string, pid int) (*DebugInfo, error) { + + exe, err := elf.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open target ELF file: %v", err) + } + defer func() { + if err := exe.Close(); err != nil { + fmt.Printf("failed to close target ELF file: %v\n", err) + } + }() + // Read line table (mapping between memory addresses and source file + line number) + lineTableData, err := exe.Section(".gopclntab").Data() + if err != nil { + return nil, fmt.Errorf("failed to read Line Table data into memory: %v", err) + } + // Address where the machine code for the target starts + addr := exe.Section(".text").Addr + // Create line table object for PCToLine and LineToPC translation + lineTable := gosym.NewLineTable(lineTableData, addr) + // Create symbol table object for looking up functions, variables and types + symTable, err := gosym.NewTable([]byte{}, lineTable) + if err != nil { + return nil, fmt.Errorf("failed to create Symbol Table: %v", err) + } + + targetFile, _, _ := symTable.PCToLine(symTable.LookupFunc("main.main").Entry) + + // Need this to wait on threads + pgid, err := syscall.Getpgid(pid) + if err != nil { + return nil, fmt.Errorf("error getting PGID: %v", err) + } + + return &DebugInfo{ + SymTable: symTable, + LineTable: lineTable, + Breakpoints: make(map[uint64][]byte), + Target: Target{ + Path: targetFile, PID: pid, PGID: pgid, + }, + }, nil +} + +func (d *DebugInfo) PCToLine(pc uint64) (file string, line int, fn *gosym.Func) { + return d.SymTable.PCToLine(pc) +} + +func (d *DebugInfo) LineToPC(file string, line int) (pc uint64, fn *gosym.Func, err error) { + return d.SymTable.LineToPC(file, line) +} + +func (d *DebugInfo) LookupFunc(fn string) *gosym.Func { + return d.SymTable.LookupFunc(fn) +} From 8c007b0745eeac1458206694bf7f4f1481548906 Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Tue, 3 Feb 2026 16:29:32 +0000 Subject: [PATCH 03/36] chore(lefthook.yml): remove unit and integration tests --- lefthook.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lefthook.yml b/lefthook.yml index edd0162..2380ebe 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -28,12 +28,6 @@ pre-commit: pre-push: commands: - test: - run: make test - - integration: - run: make integration - vet: run: go vet ./cmd/bingo From 3c384d26dc3c32f5e83a2d5c7a28c568229c1a68 Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Tue, 3 Feb 2026 16:37:49 +0000 Subject: [PATCH 04/36] chore: suggested changes --- cmd/bingo/main.go | 35 +++++++++++++++++++------------- docs/Debugger/SystemCalls.md | 16 +++++++++++++++ internal/debuginfo/debug_info.go | 5 +++-- 3 files changed, 40 insertions(+), 16 deletions(-) create mode 100644 docs/Debugger/SystemCalls.md diff --git a/cmd/bingo/main.go b/cmd/bingo/main.go index 19ed6d1..635eff0 100644 --- a/cmd/bingo/main.go +++ b/cmd/bingo/main.go @@ -7,14 +7,14 @@ import ( "os/exec" "syscall" - config "github.com/bingosuite/bingo/config" + "github.com/bingosuite/bingo/config" "github.com/bingosuite/bingo/internal/debugger" - debuginfo "github.com/bingosuite/bingo/internal/debuginfo" + "github.com/bingosuite/bingo/internal/debuginfo" websocket "github.com/bingosuite/bingo/internal/ws" ) const ( - PTRACE_O_EXITKILL = 0x100000 // Set option to kill the target process when Bingo exits to true + ptraceOExitKill = 0x100000 // Set option to kill the target process when Bingo exits to true ) func main() { @@ -46,6 +46,9 @@ func main() { if err := cmd.Start(); err != nil { handleError("Failed to start target: %v", err) } + + // We want to catch the initial SIGTRAP sent by process creation. When this is caught, we know that the target just started and we can ask the user where they want to set their breakpoints + // The message we print to the console will be removed in the future, it's just for debugging purposes for now. if err := cmd.Wait(); err != nil { log.Printf("Received SIGTRAP from process creation: %v", err) } @@ -58,10 +61,11 @@ func main() { log.Printf("Started process with PID: %d and PGID: %d\n", db.Target.PID, db.Target.PGID) // Enable tracking threads spawned from target and killing target once Bingo exits - if err := syscall.PtraceSetOptions(db.Target.PID, syscall.PTRACE_O_TRACECLONE|PTRACE_O_EXITKILL); err != nil { + if err := syscall.PtraceSetOptions(db.Target.PID, syscall.PTRACE_O_TRACECLONE|ptraceOExitKill); err != nil { handleError("Failed to set TRACECLONE and EXITKILL options on target: %v", err) } + //TODO: client should send over what line we need to set breakpoint at, not hardcoded line 11 pc, _, err := db.LineToPC(db.Target.Path, 11) if err != nil { handleError("Failed to get PC of line 11: %v", err) @@ -71,35 +75,38 @@ func main() { handleError("Failed to set breakpoint: %v", err) } - // Continue after the initial SIGTRAP, normally would ask the user what they want to do + // Continue after the initial SIGTRAP + // TODO: tell client to display the initial setup menu so the user can choose to set breakpoint, continue or single-step if err := syscall.PtraceCont(db.Target.PID, 0); err != nil { handleError("Failed to resume target execution: %v", err) } for { // Wait until any of the child processes of the target is interrupted or ends - var ws syscall.WaitStatus - wpid, err := syscall.Wait4(-1*db.Target.PGID, &ws, 0, nil) + var waitStatus syscall.WaitStatus + wpid, err := syscall.Wait4(-1*db.Target.PGID, &waitStatus, 0, nil) // TODO: handle concurrency if err != nil { handleError("Failed to wait for the target or any of its threads: %v", err) } - if ws.Exited() { + if waitStatus.Exited() { if wpid == db.Target.PID { // If target exited, terminate + log.Printf("Target %v execution completed", db.Target.Path) break + } else { + log.Printf("Thread exited with PID: %v", wpid) } } else { // Only stop on breakpoints caused by our debugger, ignore any other event like spawning of new threads - if ws.StopSignal() == syscall.SIGTRAP && ws.TrapCause() != syscall.PTRACE_EVENT_CLONE { - - //TODO: import error handling and messages and pull logic out to debugger package + if waitStatus.StopSignal() == syscall.SIGTRAP && waitStatus.TrapCause() != syscall.PTRACE_EVENT_CLONE { + //TODO: improve error handling and messages and pull logic out to debugger package // Read registers var regs syscall.PtraceRegs if err := syscall.PtraceGetRegs(wpid, ®s); err != nil { handleError("Failed to get registers: %v", err) } - filename, line, fn := db.PCToLine(regs.Rip - 1) // Interrupt advances PC by 1 on x86, so we need to rewind + filename, line, fn := db.PCToLine(regs.Rip - 1) // Breakpoint advances PC by 1 on x86, so we need to rewind fmt.Printf("Stopped at %s at %d in %s\n", fn.Name, line, filename) // Remove the breakpoint @@ -119,9 +126,9 @@ func main() { handleError("Failed to single-step: %v", err) } + // TODO: only trigger for step over signal // Wait until the program lets us know we stepped over (handle cases where we get another signal which Wait4 would consume) - var stepSig syscall.WaitStatus - if _, err := syscall.Wait4(wpid, &stepSig, 0, nil); err != nil { + if _, err := syscall.Wait4(wpid, &waitStatus, 0, nil); err != nil { handleError("Failed to wait for the single-step: %v", err) } diff --git a/docs/Debugger/SystemCalls.md b/docs/Debugger/SystemCalls.md new file mode 100644 index 0000000..3369dc1 --- /dev/null +++ b/docs/Debugger/SystemCalls.md @@ -0,0 +1,16 @@ +# Linux +## Ptrace System Calls +```Go +func PtraceAttach(pid int) (err error) // Attach the debugger to a running process +func PtraceDetach(pid int) (err error) // Detach from process +func PtracePeekData(pid int, addr uintptr, out []byte) (count int, err error) // Read a word at the address `addr` into `out` +func PtracePokeData(pid int, addr uintptr, data []byte) (count int, err error) // Copy `data` into memory at the address `addr` +func PtraceGetRegs(pid int, regsout *PtraceRegs) (err error) // Copy the target's registers into `regsout` +func PtraceSetRegs(pid int, regs *PtraceRegs) (err error) // Copy `regs` into the target's registers +func PtraceGetEventMsg(pid int) (msg uint, err error) // Returns a message about the Ptrace event that just happened +func PtraceCont(pid int, signal int) (err error) // Resume the target. If signal != 0, send the signal associated with that number as well +func PtraceSingleStep(pid int) (err error) // Resume the target and stop after the execution of a single instruction +func PtraceSetOptions(pid int, options int) (err error) // Set different Ptrace options, look at [text](https://man7.org/linux/man-pages/man2/ptrace.2.html) +func PtraceSyscall(pid int, signal int) (err error) // Resume the target and stop after entry to a system call or exit to a system call + +``` \ No newline at end of file diff --git a/internal/debuginfo/debug_info.go b/internal/debuginfo/debug_info.go index c926b83..a6910f2 100644 --- a/internal/debuginfo/debug_info.go +++ b/internal/debuginfo/debug_info.go @@ -46,7 +46,8 @@ func NewDebugInfo(path string, pid int) (*DebugInfo, error) { return nil, fmt.Errorf("failed to create Symbol Table: %v", err) } - targetFile, _, _ := symTable.PCToLine(symTable.LookupFunc("main.main").Entry) + //Need to get this to dynamically get the path to the main source Go file (ex. target.exe's source might be called /workspaces/bingo/cmd/target/target.go or /workspaces/bingo/cmd/target/main.go) + sourceFile, _, _ := symTable.PCToLine(symTable.LookupFunc("main.main").Entry) // Need this to wait on threads pgid, err := syscall.Getpgid(pid) @@ -59,7 +60,7 @@ func NewDebugInfo(path string, pid int) (*DebugInfo, error) { LineTable: lineTable, Breakpoints: make(map[uint64][]byte), Target: Target{ - Path: targetFile, PID: pid, PGID: pgid, + Path: sourceFile, PID: pid, PGID: pgid, }, }, nil } From 5304e178b582893f19db8055549eaeeb2c79f749 Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Tue, 3 Feb 2026 19:36:15 +0000 Subject: [PATCH 05/36] chore(justfile): replace Makefile with justfile --- Makefile | 35 ----------------------------------- justfile | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 35 deletions(-) delete mode 100644 Makefile create mode 100644 justfile diff --git a/Makefile b/Makefile deleted file mode 100644 index 1772a33..0000000 --- a/Makefile +++ /dev/null @@ -1,35 +0,0 @@ -.PHONY: build run go build-target run-target go-target test coverage integration - -# Extract package path from arguments (if provided) -ARGS := $(filter-out test coverage,$(MAKECMDGOALS)) -PKG := $(if $(ARGS),./$(ARGS),./...) - -build: - go build -o ./build/bingo/bingo ./cmd/bingo - -run: - ./build/bingo/bingo target - -go: build-target build run - -build-target: - go build --gcflags="all=-N -l" -o ./build/target/target ./cmd/target - -run-target: - ./build/target/target - -go-target: build-target run-target - -test: # make test internal/ws - go test -v $(PKG) - -coverage: # make coverage internal/ws - go test -coverprofile=test/coverage.out $(PKG) - go tool cover -func=test/coverage.out - -# Prevent make from treating package paths as targets -%: - @: - -integration: - go run github.com/onsi/ginkgo/v2/ginkgo -r ./test/integration/. \ No newline at end of file diff --git a/justfile b/justfile new file mode 100644 index 0000000..fe87640 --- /dev/null +++ b/justfile @@ -0,0 +1,34 @@ +# Build the BinGo binary +build: + go build -o ./build/bingo/bingo ./cmd/bingo + +# Run the BinGo binary with TARGET (defaults to "target") +run TARGET="target": + ./build/bingo/bingo {{TARGET}} + +# Build the Target, build BinGo and run the Target +go: build-target build run + +# Build the Target with maximum debugging information +build-target: + go build --gcflags="all=-N -l" -o ./build/target/target ./cmd/target + +# Run the target by itself +run-target: + ./build/target/target + +# Build and run the target by itself +go-target: build-target run-target + +# Run unit tests on the PKG (defaults to ./...) +test PKG="./...": + go test -v {{PKG}} + +# Run coverage on the PKG (defaults to ./...) +coverage PKG="./...": + go test -coverprofile=test/coverage.out {{PKG}} + go tool cover -func=test/coverage.out + +# Run integration tests +integration: + go run github.com/onsi/ginkgo/v2/ginkgo -r ./test/integration/. \ No newline at end of file From fcce23d576bcd3fbd6acdb8d4deedad814dc495d Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Tue, 3 Feb 2026 20:25:34 +0000 Subject: [PATCH 06/36] chore: add just --- .devcontainer/devcontainer.json | 3 +- cmd/bingo/main.go | 25 ++++---- internal/cli/cli.go | 102 ++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 13 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ced2962..4c06d59 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -11,7 +11,8 @@ "--platform=linux/amd64" ], "features": { - "ghcr.io/thediveo/devcontainer-features/lazygit": {} + "ghcr.io/thediveo/devcontainer-features/lazygit": {}, + "ghcr.io/jsburckhardt/devcontainer-features/just:1": {} }, "postCreateCommand": "bash .devcontainer/setup.sh", "customizations": { diff --git a/cmd/bingo/main.go b/cmd/bingo/main.go index 635eff0..c78f5f5 100644 --- a/cmd/bingo/main.go +++ b/cmd/bingo/main.go @@ -33,6 +33,7 @@ func main() { } }() + // TODO: receive this information from client procName := os.Args[1] binLocation := fmt.Sprintf("/workspaces/bingo/build/target/%s", procName) @@ -53,45 +54,45 @@ func main() { log.Printf("Received SIGTRAP from process creation: %v", err) } - db, err := debuginfo.NewDebugInfo(binLocation, cmd.Process.Pid) + dbInf, err := debuginfo.NewDebugInfo(binLocation, cmd.Process.Pid) if err != nil { log.Printf("Failed to create debug info: %v", err) panic(err) } - log.Printf("Started process with PID: %d and PGID: %d\n", db.Target.PID, db.Target.PGID) + log.Printf("Started process with PID: %d and PGID: %d\n", dbInf.Target.PID, dbInf.Target.PGID) // Enable tracking threads spawned from target and killing target once Bingo exits - if err := syscall.PtraceSetOptions(db.Target.PID, syscall.PTRACE_O_TRACECLONE|ptraceOExitKill); err != nil { + if err := syscall.PtraceSetOptions(dbInf.Target.PID, syscall.PTRACE_O_TRACECLONE|ptraceOExitKill); err != nil { handleError("Failed to set TRACECLONE and EXITKILL options on target: %v", err) } //TODO: client should send over what line we need to set breakpoint at, not hardcoded line 11 - pc, _, err := db.LineToPC(db.Target.Path, 11) + pc, _, err := dbInf.LineToPC(dbInf.Target.Path, 11) if err != nil { handleError("Failed to get PC of line 11: %v", err) } - if err := debugger.SetBreakpoint(db, pc); err != nil { + if err := debugger.SetBreakpoint(dbInf, pc); err != nil { handleError("Failed to set breakpoint: %v", err) } // Continue after the initial SIGTRAP // TODO: tell client to display the initial setup menu so the user can choose to set breakpoint, continue or single-step - if err := syscall.PtraceCont(db.Target.PID, 0); err != nil { + if err := syscall.PtraceCont(dbInf.Target.PID, 0); err != nil { handleError("Failed to resume target execution: %v", err) } for { // Wait until any of the child processes of the target is interrupted or ends var waitStatus syscall.WaitStatus - wpid, err := syscall.Wait4(-1*db.Target.PGID, &waitStatus, 0, nil) // TODO: handle concurrency + wpid, err := syscall.Wait4(-1*dbInf.Target.PGID, &waitStatus, 0, nil) // TODO: handle concurrency if err != nil { handleError("Failed to wait for the target or any of its threads: %v", err) } if waitStatus.Exited() { - if wpid == db.Target.PID { // If target exited, terminate - log.Printf("Target %v execution completed", db.Target.Path) + if wpid == dbInf.Target.PID { // If target exited, terminate + log.Printf("Target %v execution completed", dbInf.Target.Path) break } else { log.Printf("Thread exited with PID: %v", wpid) @@ -106,12 +107,12 @@ func main() { if err := syscall.PtraceGetRegs(wpid, ®s); err != nil { handleError("Failed to get registers: %v", err) } - filename, line, fn := db.PCToLine(regs.Rip - 1) // Breakpoint advances PC by 1 on x86, so we need to rewind + filename, line, fn := dbInf.PCToLine(regs.Rip - 1) // Breakpoint advances PC by 1 on x86, so we need to rewind fmt.Printf("Stopped at %s at %d in %s\n", fn.Name, line, filename) // Remove the breakpoint bpAddr := regs.Rip - 1 - if err := debugger.ClearBreakpoint(db, bpAddr); err != nil { + if err := debugger.ClearBreakpoint(dbInf, bpAddr); err != nil { handleError("Failed to clear breakpoint: %v", err) } regs.Rip = bpAddr @@ -133,7 +134,7 @@ func main() { } // Put the breakpoint back - if err := debugger.SetBreakpoint(db, bpAddr); err != nil { + if err := debugger.SetBreakpoint(dbInf, bpAddr); err != nil { handleError("Failed to set breakpoint: %v", err) } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7f1e458..825263e 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1 +1,103 @@ package cli + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" +) + +func Resume( + pid int, + targetFile string, + currentLine int, + breakpointSet bool, + originalCode []byte, + setBreak func(int, string, int) (bool, []byte), +) (bool, bool, []byte, int) { + sub := false + scanner := bufio.NewScanner(os.Stdin) + fmt.Printf("\n(C)ontinue, (S)tep, set (B)reakpoint or (Q)uit >") + for { + scanner.Scan() + input := scanner.Text() + switch strings.ToUpper(input) { + case "C": + return true, breakpointSet, originalCode, currentLine + case "S": + return false, breakpointSet, originalCode, currentLine + case "B": + fmt.Printf("\nEnter line number in %s: >", targetFile) + sub = true + case "Q": + os.Exit(0) + default: + if sub { + line, _ := strconv.Atoi(input) + breakpointSet, originalCode = setBreak(pid, targetFile, line) + return true, breakpointSet, originalCode, line + } + fmt.Printf("Unexpected input %s\n", input) + fmt.Printf("\n(C)ontinue, (S)tep, set (B)reakpoint or (Q)uit? > ") + } + } +} + +/*func outputStack(symTable *gosym.Table, pid int, ip uint64, sp uint64, bp uint64) { + + // ip = Instruction Pointer + // sp = Stack Pointer + // bp = Base(Frame) Pointer + + _, _, fn = symTable.PCToLine(ip) + var i uint64 + var nextbp uint64 + + for { + + // Only works if stack frame is [Return Address] + // [locals] + // [Saved RBP] + i = 0 + frameSize := bp - sp + 8 + + //Can happen when we look at bp and sp while they're being updated + if frameSize > 1000 || bp == 0 { + fmt.Printf("Weird frame size: SP: %X | BP: %X \n", sp, bp) + frameSize = 32 + bp = sp + frameSize - 8 + } + + // Read stack memory at sp into b + b := make([]byte, frameSize) + _, err := syscall.PtracePeekData(pid, uintptr(sp), b) + if err != nil { + panic(err) + } + + // Reads return address into content + content := binary.LittleEndian.Uint64((b[i : i+8])) + _, lineno, nextfn := symTable.PCToLine(content) + if nextfn != nil { + fn = nextfn + fmt.Printf(" called by %s line %d\n", fn.Name, lineno) + } + + //Rest of the frame + for i = 8; sp+1 <= bp; i += 8 { + content := binary.LittleEndian.Uint64(b[i : i+8]) + if sp+i == bp { + nextbp = content + } + } + + //Stop stack trace at main.main. If bp and sp are being updated we could miss main.main so we backstop with runtime.amin + if fn.Name == "main.main" || fn.Name == "runtime.main" { + break + } + + sp = sp + i + bp = nextbp + } +}*/ From f2075aebd6aef23d4d7ca777fb82013028e9462f Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Wed, 4 Feb 2026 17:27:09 +0000 Subject: [PATCH 07/36] feat: client interface with state tracking --- internal/ws/protocol.go | 52 +++++----- internal/ws/ws_test.go | 133 ------------------------ pkg/client/client.go | 223 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+), 158 deletions(-) create mode 100644 pkg/client/client.go diff --git a/internal/ws/protocol.go b/internal/ws/protocol.go index 002328d..7c3cdc0 100644 --- a/internal/ws/protocol.go +++ b/internal/ws/protocol.go @@ -7,51 +7,53 @@ type Message struct { Data json.RawMessage `json:"data,omitempty"` } +type State string + +const ( + StateExecuting State = "executing" + StateBreakpoint State = "breakpoint" +) + // Event messages (server -> client) type EventType string const ( EventSessionStarted EventType = "sessionStarted" EventStateUpdate EventType = "stateUpdate" - EventGoroutineEvent EventType = "goroutineEvent" - EventInspectResult EventType = "inspectResult" ) -type GoroutineEvent struct { - Type EventType `json:"type"` - SessionID string `json:"sessionId"` - GoroutineID uint64 `json:"goroutineId"` - State string `json:"state"` - PC string `json:"pc"` - Source struct { - File string `json:"file"` - Line int `json:"line"` - } `json:"source"` +type SessionStartedEvent struct { + Type EventType `json:"type"` + SessionID string `json:"sessionId"` + PID int `json:"pid"` } -type InspectResult struct { - Type EventType `json:"type"` - SessionID string `json:"sessionId"` - GoroutineID uint64 `json:"goroutineId"` - Vars map[string]string `json:"vars"` +type StateUpdateEvent struct { + Type EventType `json:"type"` + SessionID string `json:"sessionId"` + NewState State `json:"newState"` } // Command messages (client -> server) type CommandType string const ( - CmdContinue CommandType = "continue" - CmdStepOver CommandType = "stepOver" - CmdInspectGoroutine CommandType = "inspectGoroutine" + CmdContinue CommandType = "continue" + CmdStepOver CommandType = "stepOver" + CmdExit CommandType = "exit" ) -type InspectGoroutineCmd struct { - Type CommandType `json:"type"` - SessionID string `json:"sessionId"` - GoroutineID uint64 `json:"goroutineId"` +type ContinueCmd struct { + Type CommandType `json:"type"` + SessionID string `json:"sessionId"` +} + +type StepOverCmd struct { + Type CommandType `json:"type"` + SessionID string `json:"sessionId"` } -type ContinueCmd struct { +type ExitCmd struct { Type CommandType `json:"type"` SessionID string `json:"sessionId"` } diff --git a/internal/ws/ws_test.go b/internal/ws/ws_test.go index 66ad4f7..833c55d 100644 --- a/internal/ws/ws_test.go +++ b/internal/ws/ws_test.go @@ -116,42 +116,6 @@ var _ = Describe("Hub", func() { }) }) - Describe("Broadcast", func() { - It("should broadcast messages to all clients", func() { - dialer := websocket.Dialer{} - - conn1, _, _ := dialer.Dial(wsURL, nil) - defer func() { _ = conn1.Close() }() - client1 := NewClient(conn1, hub, "client-1") - - conn2, _, _ := dialer.Dial(wsURL, nil) - defer func() { _ = conn2.Close() }() - client2 := NewClient(conn2, hub, "client-2") - - hub.Register(client1) - hub.Register(client2) - - time.Sleep(50 * time.Millisecond) - - eventData, _ := json.Marshal(GoroutineEvent{ - Type: EventGoroutineEvent, - SessionID: "test-session", - GoroutineID: 1, - State: "running", - }) - message := Message{ - Type: string(EventGoroutineEvent), - Data: eventData, - } - - hub.Broadcast(message) - - time.Sleep(50 * time.Millisecond) - - Expect(len(client1.send) == 0 || len(client2.send) == 0).To(BeFalse()) - }) - }) - Describe("SendCommand", func() { It("should send commands to hub", func() { cmdData, _ := json.Marshal(ContinueCmd{ @@ -767,81 +731,6 @@ var _ = Describe("Protocol", func() { Expect(unmarshaledMsg.Type).To(Equal(msg.Type)) }) - - It("should encode and decode JSON", func() { - originalMsg := Message{ - Type: string(EventGoroutineEvent), - Data: json.RawMessage(`{"type":"goroutineEvent","goroutineId":123}`), - } - - jsonData, err := json.Marshal(originalMsg) - Expect(err).NotTo(HaveOccurred()) - - var decodedMsg Message - err = json.Unmarshal(jsonData, &decodedMsg) - Expect(err).NotTo(HaveOccurred()) - - Expect(decodedMsg.Type).To(Equal(originalMsg.Type)) - Expect(decodedMsg.Data).To(Equal(originalMsg.Data)) - }) - }) - - Describe("GoroutineEvent", func() { - It("should handle GoroutineEvent struct", func() { - event := GoroutineEvent{ - Type: EventGoroutineEvent, - SessionID: "session-1", - GoroutineID: 42, - State: "running", - PC: "0x12345", - } - event.Source.File = "main.go" - event.Source.Line = 10 - - Expect(event.Type).To(Equal(EventGoroutineEvent)) - Expect(event.GoroutineID).To(Equal(uint64(42))) - Expect(event.Source.File).To(Equal("main.go")) - Expect(event.Source.Line).To(Equal(10)) - - data, err := json.Marshal(event) - Expect(err).NotTo(HaveOccurred()) - - var unmarshaled GoroutineEvent - err = json.Unmarshal(data, &unmarshaled) - Expect(err).NotTo(HaveOccurred()) - - Expect(unmarshaled.GoroutineID).To(Equal(event.GoroutineID)) - Expect(unmarshaled.Source.File).To(Equal(event.Source.File)) - }) - }) - - Describe("InspectResult", func() { - It("should handle InspectResult struct", func() { - result := InspectResult{ - Type: EventInspectResult, - SessionID: "session-1", - GoroutineID: 42, - Vars: map[string]string{ - "x": "10", - "y": "20", - }, - } - - Expect(result.Type).To(Equal(EventInspectResult)) - Expect(result.GoroutineID).To(Equal(uint64(42))) - Expect(result.Vars["x"]).To(Equal("10")) - Expect(result.Vars["y"]).To(Equal("20")) - - data, err := json.Marshal(result) - Expect(err).NotTo(HaveOccurred()) - - var unmarshaled InspectResult - err = json.Unmarshal(data, &unmarshaled) - Expect(err).NotTo(HaveOccurred()) - - Expect(unmarshaled.GoroutineID).To(Equal(result.GoroutineID)) - Expect(unmarshaled.Vars["x"]).To(Equal(result.Vars["x"])) - }) }) Describe("ContinueCmd", func() { @@ -864,26 +753,4 @@ var _ = Describe("Protocol", func() { Expect(unmarshaled.SessionID).To(Equal(cmd.SessionID)) }) }) - - Describe("InspectGoroutineCmd", func() { - It("should handle InspectGoroutineCmd struct", func() { - cmd := InspectGoroutineCmd{ - Type: CmdInspectGoroutine, - SessionID: "session-1", - GoroutineID: 42, - } - - Expect(cmd.Type).To(Equal(CmdInspectGoroutine)) - Expect(cmd.GoroutineID).To(Equal(uint64(42))) - - data, err := json.Marshal(cmd) - Expect(err).NotTo(HaveOccurred()) - - var unmarshaled InspectGoroutineCmd - err = json.Unmarshal(data, &unmarshaled) - Expect(err).NotTo(HaveOccurred()) - - Expect(unmarshaled.GoroutineID).To(Equal(cmd.GoroutineID)) - }) - }) }) diff --git a/pkg/client/client.go b/pkg/client/client.go new file mode 100644 index 0000000..c86ea61 --- /dev/null +++ b/pkg/client/client.go @@ -0,0 +1,223 @@ +package client + +import ( + "encoding/json" + "fmt" + "log" + "net/url" + "sync/atomic" + + "github.com/bingosuite/bingo/internal/ws" + "github.com/gorilla/websocket" +) + +type Client struct { + serverURL string + sessionID string + conn *websocket.Conn + send chan ws.Message + done chan struct{} + state atomic.Value // ws.State +} + +func NewClient(serverURL, sessionID string) *Client { + c := &Client{ + serverURL: serverURL, + sessionID: sessionID, + send: make(chan ws.Message, 256), + done: make(chan struct{}), + } + c.state.Store(ws.StateExecuting) + return c +} + +func (c *Client) Connect() error { + // Build WebSocket URL with session ID + u := url.URL{ + Scheme: "ws", + Host: c.serverURL, + Path: "/ws/", + RawQuery: "session=" + c.sessionID, + } + log.Printf("Connecting to %s", u.String()) + + conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + return fmt.Errorf("dial error: %w", err) + } + + c.conn = conn + log.Println("Connected to server") + return nil +} + +func (c *Client) Run() error { + if c.conn == nil { + return fmt.Errorf("connection not established") + } + + // Start read and write pumps + go c.readPump() + go c.writePump() + + return nil +} + +func (c *Client) readPump() { + defer func() { + close(c.done) + if err := c.conn.Close(); err != nil { + log.Printf("Close error: %v", err) + } + }() + + for { + var msg ws.Message + if err := c.conn.ReadJSON(&msg); err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + log.Printf("WebSocket error: %v", err) + } + return + } + + c.handleMessage(msg) + } +} + +func (c *Client) writePump() { + for message := range c.send { + if err := c.conn.WriteJSON(message); err != nil { + log.Printf("Write error: %v", err) + return + } + } + if err := c.conn.WriteMessage(websocket.CloseMessage, []byte{}); err != nil { + log.Printf("Failed to close websocket: %v", err) + return + } + c.state.Store(ws.StateExecuting) +} + +func (c *Client) handleMessage(msg ws.Message) { + log.Printf("Received message type: %s", msg.Type) + + switch ws.EventType(msg.Type) { + case ws.EventSessionStarted: + log.Println("Debug session started") + + case ws.EventStateUpdate: + var update ws.StateUpdateEvent + if err := unmarshalData(msg.Data, &update); err != nil { + log.Printf("Error parsing stateUpdate: %v", err) + return + } + c.setState(update.NewState) + log.Printf("State updated: %s", update.NewState) + + default: + log.Printf("Unknown message type: %s", msg.Type) + } +} + +func unmarshalData(data []byte, v interface{}) error { + // Handle empty data + if len(data) == 0 { + return nil + } + return unmarshalJSON(data, v) +} + +func unmarshalJSON(data []byte, v interface{}) error { + return json.Unmarshal(data, v) +} + +func (c *Client) SendCommand(cmdType string, payload []byte) error { + msg := ws.Message{ + Type: cmdType, + Data: payload, + } + + select { + case c.send <- msg: + log.Printf("Queued command: %s", cmdType) + return nil + case <-c.done: + return fmt.Errorf("connection closed") + } +} + +func (c *Client) Continue() error { + cmd := ws.ContinueCmd{ + Type: ws.CmdContinue, + SessionID: c.sessionID, + } + payload, err := marshalJSON(cmd) + if err != nil { + return err + } + return c.SendCommand(string(ws.CmdContinue), payload) +} + +func (c *Client) StepOver() error { + cmd := ws.StepOverCmd{ + Type: ws.CmdStepOver, + SessionID: c.sessionID, + } + payload, err := marshalJSON(cmd) + if err != nil { + return err + } + return c.SendCommand(string(ws.CmdStepOver), payload) +} + +func marshalJSON(v interface{}) ([]byte, error) { + return json.Marshal(v) +} + +func (c *Client) setState(state ws.State) { + c.state.Store(state) +} + +func (c *Client) State() ws.State { + return c.state.Load().(ws.State) +} + +func (c *Client) Close() error { + if c.conn != nil { + return c.conn.Close() + } + return nil +} + +// func main() { +// serverAddr := flag.String("server", "localhost:8080", "Server address") +// sessionID := flag.String("session", "test-session", "Session ID") +// flag.Parse() + +// client := NewClient(*serverAddr, *sessionID) + +// // Connect to the server +// if err := client.Connect(); err != nil { +// log.Fatalf("Failed to connect: %v", err) +// } +// if err := client.Run(); err != nil { +// log.Fatalf("Failed to start client: %v", err) +// } +// defer func() { +// if err := client.Close(); err != nil { +// log.Printf("Client close error: %v", err) +// } +// }() + +// // Set up interrupt handler +// interrupt := make(chan os.Signal, 1) +// signal.Notify(interrupt, os.Interrupt) + +// // Wait for interrupt or server close +// select { +// case <-interrupt: +// log.Println("Interrupt signal received") +// case <-client.done: +// log.Println("Server closed connection") +// } +// } From b666c52eaf33305d50d19929d2ad8c9c5192f35a Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Wed, 4 Feb 2026 16:06:27 +0000 Subject: [PATCH 08/36] wip: refactor logic into debugger package --- cmd/bingo/main.go | 138 +------------------ cmd/target/target.go | 10 +- internal/debugger/debugger.go | 227 ++++++++++++++++++++++++++++++- internal/debuginfo/debug_info.go | 12 +- justfile | 9 +- 5 files changed, 240 insertions(+), 156 deletions(-) diff --git a/cmd/bingo/main.go b/cmd/bingo/main.go index c78f5f5..fd4e7a2 100644 --- a/cmd/bingo/main.go +++ b/cmd/bingo/main.go @@ -1,22 +1,13 @@ package main import ( - "fmt" "log" - "os" - "os/exec" - "syscall" "github.com/bingosuite/bingo/config" "github.com/bingosuite/bingo/internal/debugger" - "github.com/bingosuite/bingo/internal/debuginfo" websocket "github.com/bingosuite/bingo/internal/ws" ) -const ( - ptraceOExitKill = 0x100000 // Set option to kill the target process when Bingo exits to true -) - func main() { cfg, err := config.Load("config/config.yml") if err != nil { @@ -33,129 +24,8 @@ func main() { } }() - // TODO: receive this information from client - procName := os.Args[1] - binLocation := fmt.Sprintf("/workspaces/bingo/build/target/%s", procName) - - // Set up target for execution - cmd := exec.Command(binLocation) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.SysProcAttr = &syscall.SysProcAttr{Ptrace: true} - - if err := cmd.Start(); err != nil { - handleError("Failed to start target: %v", err) - } - - // We want to catch the initial SIGTRAP sent by process creation. When this is caught, we know that the target just started and we can ask the user where they want to set their breakpoints - // The message we print to the console will be removed in the future, it's just for debugging purposes for now. - if err := cmd.Wait(); err != nil { - log.Printf("Received SIGTRAP from process creation: %v", err) - } - - dbInf, err := debuginfo.NewDebugInfo(binLocation, cmd.Process.Pid) - if err != nil { - log.Printf("Failed to create debug info: %v", err) - panic(err) - } - log.Printf("Started process with PID: %d and PGID: %d\n", dbInf.Target.PID, dbInf.Target.PGID) - - // Enable tracking threads spawned from target and killing target once Bingo exits - if err := syscall.PtraceSetOptions(dbInf.Target.PID, syscall.PTRACE_O_TRACECLONE|ptraceOExitKill); err != nil { - handleError("Failed to set TRACECLONE and EXITKILL options on target: %v", err) - } - - //TODO: client should send over what line we need to set breakpoint at, not hardcoded line 11 - pc, _, err := dbInf.LineToPC(dbInf.Target.Path, 11) - if err != nil { - handleError("Failed to get PC of line 11: %v", err) - } - - if err := debugger.SetBreakpoint(dbInf, pc); err != nil { - handleError("Failed to set breakpoint: %v", err) - } - - // Continue after the initial SIGTRAP - // TODO: tell client to display the initial setup menu so the user can choose to set breakpoint, continue or single-step - if err := syscall.PtraceCont(dbInf.Target.PID, 0); err != nil { - handleError("Failed to resume target execution: %v", err) - } - - for { - // Wait until any of the child processes of the target is interrupted or ends - var waitStatus syscall.WaitStatus - wpid, err := syscall.Wait4(-1*dbInf.Target.PGID, &waitStatus, 0, nil) // TODO: handle concurrency - if err != nil { - handleError("Failed to wait for the target or any of its threads: %v", err) - } - - if waitStatus.Exited() { - if wpid == dbInf.Target.PID { // If target exited, terminate - log.Printf("Target %v execution completed", dbInf.Target.Path) - break - } else { - log.Printf("Thread exited with PID: %v", wpid) - } - } else { - // Only stop on breakpoints caused by our debugger, ignore any other event like spawning of new threads - if waitStatus.StopSignal() == syscall.SIGTRAP && waitStatus.TrapCause() != syscall.PTRACE_EVENT_CLONE { - //TODO: improve error handling and messages and pull logic out to debugger package - - // Read registers - var regs syscall.PtraceRegs - if err := syscall.PtraceGetRegs(wpid, ®s); err != nil { - handleError("Failed to get registers: %v", err) - } - filename, line, fn := dbInf.PCToLine(regs.Rip - 1) // Breakpoint advances PC by 1 on x86, so we need to rewind - fmt.Printf("Stopped at %s at %d in %s\n", fn.Name, line, filename) - - // Remove the breakpoint - bpAddr := regs.Rip - 1 - if err := debugger.ClearBreakpoint(dbInf, bpAddr); err != nil { - handleError("Failed to clear breakpoint: %v", err) - } - regs.Rip = bpAddr - - // Rewind Rip by 1 - if err := syscall.PtraceSetRegs(wpid, ®s); err != nil { - handleError("Failed to restore registers: %v", err) - } - - // Step over the instruction we previously removed to put the breakpoint - if err := syscall.PtraceSingleStep(wpid); err != nil { - handleError("Failed to single-step: %v", err) - } - - // TODO: only trigger for step over signal - // Wait until the program lets us know we stepped over (handle cases where we get another signal which Wait4 would consume) - if _, err := syscall.Wait4(wpid, &waitStatus, 0, nil); err != nil { - handleError("Failed to wait for the single-step: %v", err) - } - - // Put the breakpoint back - if err := debugger.SetBreakpoint(dbInf, bpAddr); err != nil { - handleError("Failed to set breakpoint: %v", err) - } - - // Resume execution - if err := syscall.PtraceCont(wpid, 0); err != nil { - handleError("Failed to resume target execution: %v", err) - } - - } else { - if err := syscall.PtraceCont(wpid, 0); err != nil { - handleError("Failed to resume target execution: %v", err) - } - } - } - - } - -} - -func handleError(msg string, err error) { - log.Printf(msg, err) - panic(err) - + d := debugger.NewDebugger() + // TODO: server tells debugger how to start the debugging session by passing path to start with debug or pid to attach + go d.StartWithDebug("/workspaces/bingo/build/target/target") + <-d.EndDebugSession } diff --git a/cmd/target/target.go b/cmd/target/target.go index c9fd013..63a43fc 100644 --- a/cmd/target/target.go +++ b/cmd/target/target.go @@ -6,11 +6,7 @@ import ( ) func main() { - var count int - for { - fmt.Println("Hello, World") - count = count + 1 - count = count * 1 - time.Sleep(2 * time.Second) - } + fmt.Println("Hello, World") + time.Sleep(2 * time.Second) + } diff --git a/internal/debugger/debugger.go b/internal/debugger/debugger.go index 89e20d4..3355bca 100644 --- a/internal/debugger/debugger.go +++ b/internal/debugger/debugger.go @@ -2,6 +2,9 @@ package debugger import ( "fmt" + "log" + "os" + "os/exec" "syscall" "github.com/bingosuite/bingo/internal/debuginfo" @@ -11,21 +14,235 @@ var ( bpCode = []byte{0xCC} ) -func SetBreakpoint(d *debuginfo.DebugInfo, pc uint64) error { +const ( + ptraceOExitKill = 0x100000 // Set option to kill the target process when Bingo exits to true +) + +type Debugger struct { + DebugInfo debuginfo.DebugInfo + Breakpoints map[uint64][]byte + EndDebugSession chan bool +} + +func NewDebugger() *Debugger { + return &Debugger{ + Breakpoints: make(map[uint64][]byte), + EndDebugSession: make(chan bool, 1), + } +} + +func (d *Debugger) StartWithDebug(path string) { + + // Set up target for execution + cmd := exec.Command(path) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.SysProcAttr = &syscall.SysProcAttr{Ptrace: true} + + if err := cmd.Start(); err != nil { + log.Printf("Failed to start target: %v", err) + panic(err) + } + + /* + // We want to catch the initial SIGTRAP sent by process creation. When this is caught, we know that the target just started and we can ask the user where they want to set their breakpoints + // The message we print to the console will be removed in the future, it's just for debugging purposes for now. + if err := cmd.Wait(); err != nil { + log.Printf("Received SIGTRAP from process creation: %v", err) + }*/ + + dbInf, err := debuginfo.NewDebugInfo(path, cmd.Process.Pid) + if err != nil { + log.Printf("Failed to create debug info: %v", err) + panic(err) + } + log.Printf("Started process with PID: %d and PGID: %d\n", dbInf.Target.PID, dbInf.Target.PGID) + + // Enable tracking threads spawned from target and killing target once Bingo exits + if err := syscall.PtraceSetOptions(dbInf.Target.PID, syscall.PTRACE_O_TRACECLONE|ptraceOExitKill); err != nil { + log.Printf("Failed to set TRACECLONE and EXITKILL options on target: %v", err) + panic(err) + } + + d.DebugInfo = *dbInf + + // We want to catch the initial SIGTRAP sent by process creation. When this is caught, we know that the target just started and we can ask the user where they want to set their breakpoints + // The message we print to the console will be removed in the future, it's just for debugging purposes for now. + if err := cmd.Wait(); err != nil { + log.Printf("Received SIGTRAP from process creation: %v", err) + } + + // Set initial breakpoints while the process is stopped at the initial SIGTRAP + d.initialBreakpointHit() + + log.Println("STARTING DEBUG LOOP") + + d.debug() + + // Wait until debug session exits + d.EndDebugSession <- true + +} + +// TODO: figure out how to do +func (d *Debugger) AttachAndDebug(pid int) { + +} + +func (d *Debugger) Continue(pid int) { + // Read registers + var regs syscall.PtraceRegs + if err := syscall.PtraceGetRegs(pid, ®s); err != nil { + log.Printf("Failed to get registers: %v", err) + return // Process likely exited, gracefully return + } + filename, line, fn := d.DebugInfo.PCToLine(regs.Rip - 1) // Breakpoint advances PC by 1 on x86, so we need to rewind + fmt.Printf("Stopped at %s at %d in %s\n", fn.Name, line, filename) + + // Remove the breakpoint + bpAddr := regs.Rip - 1 + if err := d.ClearBreakpoint(line); err != nil { + log.Printf("Failed to clear breakpoint: %v", err) + panic(err) + } + regs.Rip = bpAddr + + // Rewind Rip by 1 + if err := syscall.PtraceSetRegs(pid, ®s); err != nil { + log.Printf("Failed to restore registers: %v", err) + panic(err) + } + + // Step over the instruction we previously removed to put the breakpoint + // TODO: decide if we want to call debugger.SingleStep() for this or just the system call + if err := syscall.PtraceSingleStep(pid); err != nil { + log.Printf("Failed to single-step: %v", err) + panic(err) + } + + // TODO: only trigger for step over signal + var waitStatus syscall.WaitStatus + // Wait until the program lets us know we stepped over (handle cases where we get another signal which Wait4 would consume) + if _, err := syscall.Wait4(pid, &waitStatus, 0, nil); err != nil { + log.Printf("Failed to wait for the single-step: %v", err) + panic(err) + } + + // Put the breakpoint back + if err := d.SetBreakpoint(line); err != nil { + log.Printf("Failed to set breakpoint: %v", err) + panic(err) + } + + // Resume execution + // TODO: decide if we want to call debugger.Continue() for this or just the system call + if err := syscall.PtraceCont(pid, 0); err != nil { + log.Printf("Failed to resume target execution: %v", err) + panic(err) + } + +} + +func (d *Debugger) SingleStep(pid int) { + + if err := syscall.PtraceSingleStep(pid); err != nil { + log.Printf("Failed to single-step: %v", err) + panic(err) + } + +} + +func (d *Debugger) StopDebug() { + d.EndDebugSession <- true +} + +func (d *Debugger) SetBreakpoint(line int) error { + + pc, _, err := d.DebugInfo.LineToPC(d.DebugInfo.Target.Path, line) + if err != nil { + log.Printf("Failed to get PC of line %v: %v", line, err) + panic(err) + } + original := make([]byte, len(bpCode)) - if _, err := syscall.PtracePeekData(d.Target.PID, uintptr(pc), original); err != nil { + if _, err := syscall.PtracePeekData(d.DebugInfo.Target.PID, uintptr(pc), original); err != nil { return fmt.Errorf("failed to read original machine code into memory: %v", err) } - if _, err := syscall.PtracePokeData(d.Target.PID, uintptr(pc), bpCode); err != nil { + if _, err := syscall.PtracePokeData(d.DebugInfo.Target.PID, uintptr(pc), bpCode); err != nil { return fmt.Errorf("failed to write breakpoint into memory: %v", err) } d.Breakpoints[pc] = original return nil } -func ClearBreakpoint(d *debuginfo.DebugInfo, pc uint64) error { - if _, err := syscall.PtracePokeData(d.Target.PID, uintptr(pc), d.Breakpoints[pc]); err != nil { +func (d *Debugger) ClearBreakpoint(line int) error { + + pc, _, err := d.DebugInfo.LineToPC(d.DebugInfo.Target.Path, line) + if err != nil { + log.Printf("Failed to get PC of line %v: %v", line, err) + panic(err) + } + if _, err := syscall.PtracePokeData(d.DebugInfo.Target.PID, uintptr(pc), d.Breakpoints[pc]); err != nil { return fmt.Errorf("failed to write breakpoint into memory: %v", err) } return nil } + +func (d *Debugger) debug() { + for { + // Wait until any of the child processes of the target is interrupted or ends + var waitStatus syscall.WaitStatus + wpid, err := syscall.Wait4(-1*d.DebugInfo.Target.PGID, &waitStatus, 0, nil) // TODO: handle concurrency + if err != nil { + log.Printf("Failed to wait for the target or any of its threads: %v", err) + panic(err) + } + + if waitStatus.Exited() { + if wpid == d.DebugInfo.Target.PID { // If target exited, terminate + log.Printf("Target %v execution completed", d.DebugInfo.Target.Path) + break + } else { + log.Printf("Thread exited with PID: %v", wpid) + } + } else { + // Only stop on breakpoints caused by our debugger, ignore any other event like spawning of new threads + if waitStatus.StopSignal() == syscall.SIGTRAP && waitStatus.TrapCause() != syscall.PTRACE_EVENT_CLONE { + //TODO: improve error handling and messages + + d.breakpointHit(wpid) + + } else { + if err := syscall.PtraceCont(wpid, 0); err != nil { + log.Printf("Failed to resume target execution: %v", err) + panic(err) + } + } + } + } +} + +func (d *Debugger) initialBreakpointHit() { + // TODO: NUKE, forward necessary information to the server instead + log.Println("INITIAL BREAKPOINT HIT") + + //TODO: tell server we hit the initial breakpoint and need to know what to do (continue, set bp, step over, quit) + if err := d.SetBreakpoint(9); err != nil { + log.Printf("Failed to set breakpoint: %v", err) + panic(err) + } + // When initial breakpoint is hit, resume execution like this instead of d.Continue() after receiving continue from the server + if err := syscall.PtraceCont(d.DebugInfo.Target.PID, 0); err != nil { + log.Printf("Failed to resume target execution: %v", err) + panic(err) + } +} + +func (d *Debugger) breakpointHit(pid int) { + // TODO: NUKE, forward necessary information to the server instead + log.Println("BREAKPOINT HIT") + + //TODO: select with channels from server that tell the debugger whether to continue, single step, set breakpoint or quite + d.Continue(pid) +} diff --git a/internal/debuginfo/debug_info.go b/internal/debuginfo/debug_info.go index a6910f2..58ad901 100644 --- a/internal/debuginfo/debug_info.go +++ b/internal/debuginfo/debug_info.go @@ -14,10 +14,9 @@ type Target struct { } type DebugInfo struct { - SymTable *gosym.Table - LineTable *gosym.LineTable - Breakpoints map[uint64][]byte - Target Target + SymTable *gosym.Table + LineTable *gosym.LineTable + Target Target } func NewDebugInfo(path string, pid int) (*DebugInfo, error) { @@ -56,9 +55,8 @@ func NewDebugInfo(path string, pid int) (*DebugInfo, error) { } return &DebugInfo{ - SymTable: symTable, - LineTable: lineTable, - Breakpoints: make(map[uint64][]byte), + SymTable: symTable, + LineTable: lineTable, Target: Target{ Path: sourceFile, PID: pid, PGID: pgid, }, diff --git a/justfile b/justfile index fe87640..c954e8a 100644 --- a/justfile +++ b/justfile @@ -3,8 +3,8 @@ build: go build -o ./build/bingo/bingo ./cmd/bingo # Run the BinGo binary with TARGET (defaults to "target") -run TARGET="target": - ./build/bingo/bingo {{TARGET}} +run: + ./build/bingo/bingo # Build the Target, build BinGo and run the Target go: build-target build run @@ -31,4 +31,7 @@ coverage PKG="./...": # Run integration tests integration: - go run github.com/onsi/ginkgo/v2/ginkgo -r ./test/integration/. \ No newline at end of file + go run github.com/onsi/ginkgo/v2/ginkgo -r ./test/integration/. + +client: + go run ./cmd/client/client.go -server localhost:8080 -session test-session \ No newline at end of file From 934f23805cf212c1337ed85adc3caf1ded95bc60 Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Wed, 4 Feb 2026 17:30:27 +0000 Subject: [PATCH 09/36] docs: clean up --- cmd/target/target.go | 10 +++++++--- internal/debugger/debugger.go | 13 +++++-------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/cmd/target/target.go b/cmd/target/target.go index 63a43fc..c9fd013 100644 --- a/cmd/target/target.go +++ b/cmd/target/target.go @@ -6,7 +6,11 @@ import ( ) func main() { - fmt.Println("Hello, World") - time.Sleep(2 * time.Second) - + var count int + for { + fmt.Println("Hello, World") + count = count + 1 + count = count * 1 + time.Sleep(2 * time.Second) + } } diff --git a/internal/debugger/debugger.go b/internal/debugger/debugger.go index 3355bca..05532ff 100644 --- a/internal/debugger/debugger.go +++ b/internal/debugger/debugger.go @@ -45,13 +45,6 @@ func (d *Debugger) StartWithDebug(path string) { panic(err) } - /* - // We want to catch the initial SIGTRAP sent by process creation. When this is caught, we know that the target just started and we can ask the user where they want to set their breakpoints - // The message we print to the console will be removed in the future, it's just for debugging purposes for now. - if err := cmd.Wait(); err != nil { - log.Printf("Received SIGTRAP from process creation: %v", err) - }*/ - dbInf, err := debuginfo.NewDebugInfo(path, cmd.Process.Pid) if err != nil { log.Printf("Failed to create debug info: %v", err) @@ -228,7 +221,11 @@ func (d *Debugger) initialBreakpointHit() { log.Println("INITIAL BREAKPOINT HIT") //TODO: tell server we hit the initial breakpoint and need to know what to do (continue, set bp, step over, quit) - if err := d.SetBreakpoint(9); err != nil { + if err := d.SetBreakpoint(11); err != nil { + log.Printf("Failed to set breakpoint: %v", err) + panic(err) + } + if err := d.SetBreakpoint(13); err != nil { log.Printf("Failed to set breakpoint: %v", err) panic(err) } From b9d3414fb38de72f7c2d6213b9ff094ad9d88eb3 Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Wed, 4 Feb 2026 17:37:58 +0000 Subject: [PATCH 10/36] fix: prevent client from sending command while not on breakpoint --- pkg/client/client.go | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/pkg/client/client.go b/pkg/client/client.go index c86ea61..20e47c3 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -5,7 +5,7 @@ import ( "fmt" "log" "net/url" - "sync/atomic" + "sync" "github.com/bingosuite/bingo/internal/ws" "github.com/gorilla/websocket" @@ -17,7 +17,8 @@ type Client struct { conn *websocket.Conn send chan ws.Message done chan struct{} - state atomic.Value // ws.State + stateMu sync.RWMutex + state ws.State } func NewClient(serverURL, sessionID string) *Client { @@ -26,8 +27,8 @@ func NewClient(serverURL, sessionID string) *Client { sessionID: sessionID, send: make(chan ws.Message, 256), done: make(chan struct{}), + state: ws.StateExecuting, } - c.state.Store(ws.StateExecuting) return c } @@ -95,7 +96,7 @@ func (c *Client) writePump() { log.Printf("Failed to close websocket: %v", err) return } - c.state.Store(ws.StateExecuting) + c.setState(ws.StateExecuting) } func (c *Client) handleMessage(msg ws.Message) { @@ -119,7 +120,7 @@ func (c *Client) handleMessage(msg ws.Message) { } } -func unmarshalData(data []byte, v interface{}) error { +func unmarshalData(data []byte, v any) error { // Handle empty data if len(data) == 0 { return nil @@ -127,11 +128,15 @@ func unmarshalData(data []byte, v interface{}) error { return unmarshalJSON(data, v) } -func unmarshalJSON(data []byte, v interface{}) error { +func unmarshalJSON(data []byte, v any) error { return json.Unmarshal(data, v) } func (c *Client) SendCommand(cmdType string, payload []byte) error { + // TODO: decide which states allow which commands + if c.State() != ws.StateBreakpoint { + return fmt.Errorf("cannot send command in state: %s", c.State()) + } msg := ws.Message{ Type: cmdType, Data: payload, @@ -170,16 +175,20 @@ func (c *Client) StepOver() error { return c.SendCommand(string(ws.CmdStepOver), payload) } -func marshalJSON(v interface{}) ([]byte, error) { +func marshalJSON(v any) ([]byte, error) { return json.Marshal(v) } func (c *Client) setState(state ws.State) { - c.state.Store(state) + c.stateMu.Lock() + defer c.stateMu.Unlock() + c.state = state } func (c *Client) State() ws.State { - return c.state.Load().(ws.State) + c.stateMu.RLock() + defer c.stateMu.RUnlock() + return c.state } func (c *Client) Close() error { @@ -189,6 +198,7 @@ func (c *Client) Close() error { return nil } +// Example usage: // func main() { // serverAddr := flag.String("server", "localhost:8080", "Server address") // sessionID := flag.String("session", "test-session", "Session ID") From 48e6ecf0329c21f46fe7094e23d1fa35593a2ce7 Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Wed, 4 Feb 2026 18:24:27 +0000 Subject: [PATCH 11/36] refactor: rename server-side client to connection --- internal/ws/{client.go => connection.go} | 18 ++--- internal/ws/hub.go | 52 ++++++------- internal/ws/server.go | 2 +- internal/ws/ws_test.go | 98 ++++++++++++------------ 4 files changed, 84 insertions(+), 86 deletions(-) rename internal/ws/{client.go => connection.go} (66%) diff --git a/internal/ws/client.go b/internal/ws/connection.go similarity index 66% rename from internal/ws/client.go rename to internal/ws/connection.go index a4f55af..e3bb70f 100644 --- a/internal/ws/client.go +++ b/internal/ws/connection.go @@ -6,15 +6,15 @@ import ( "github.com/gorilla/websocket" ) -type Client struct { +type Connection struct { id string conn *websocket.Conn hub *Hub send chan Message } -func NewClient(conn *websocket.Conn, hub *Hub, id string) *Client { - return &Client{ +func NewConnection(conn *websocket.Conn, hub *Hub, id string) *Connection { + return &Connection{ id: id, conn: conn, hub: hub, @@ -22,11 +22,11 @@ func NewClient(conn *websocket.Conn, hub *Hub, id string) *Client { } } -func (c *Client) ReadPump() { +func (c *Connection) ReadPump() { defer func() { c.hub.Unregister(c) if err := c.conn.Close(); err != nil { - log.Printf("Client %s close error: %v", c.id, err) + log.Printf("Connection %s close error: %v", c.id, err) } }() @@ -34,7 +34,7 @@ func (c *Client) ReadPump() { var msg Message err := c.conn.ReadJSON(&msg) if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { - log.Printf("Client %s unexpected close: %v", c.id, err) + log.Printf("Connection %s unexpected close: %v", c.id, err) break } if err != nil { @@ -44,16 +44,16 @@ func (c *Client) ReadPump() { } } -func (c *Client) WritePump() { +func (c *Connection) WritePump() { defer func() { if err := c.conn.Close(); err != nil { - log.Printf("Client %s close error: %v", c.id, err) + log.Printf("Connection %s close error: %v", c.id, err) } }() for message := range c.send { if err := c.conn.WriteJSON(message); err != nil { - log.Printf("Client %s write error: %v", c.id, err) + log.Printf("Connection %s write error: %v", c.id, err) return } } diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 39140f3..ccc23dc 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -14,12 +14,12 @@ const ( ) type Hub struct { - sessionID string - clients map[*Client]struct{} + sessionID string + connections map[*Connection]struct{} // Channels for register/unregister clients and broadcast msgs - register chan *Client - unregister chan *Client + register chan *Connection + unregister chan *Connection events chan Message commands chan Message @@ -35,9 +35,9 @@ type Hub struct { func NewHub(sessionID string, idleTimeout time.Duration) *Hub { return &Hub{ sessionID: sessionID, - clients: make(map[*Client]struct{}), - register: make(chan *Client), - unregister: make(chan *Client), + connections: make(map[*Connection]struct{}), + register: make(chan *Connection), + unregister: make(chan *Connection), events: make(chan Message, eventBufferSize), commands: make(chan Message, commandBufferSize), idleTimeout: idleTimeout, @@ -53,7 +53,7 @@ func (h *Hub) Run() { select { case <-ticker.C: // Check idle timeout - if h.idleTimeout > 0 && len(h.clients) == 0 { + if h.idleTimeout > 0 && len(h.connections) == 0 { if time.Since(h.lastActivity) > h.idleTimeout { log.Printf("Session %s idle for %v, shutting down", h.sessionID, h.idleTimeout) h.shutdown() @@ -63,20 +63,20 @@ func (h *Hub) Run() { case client := <-h.register: h.mu.Lock() - h.clients[client] = struct{}{} + h.connections[client] = struct{}{} h.lastActivity = time.Now() h.mu.Unlock() - log.Printf("Client %s connected to hub %s (%d total)", client.id, h.sessionID, len(h.clients)) + log.Printf("Client %s connected to hub %s (%d total)", client.id, h.sessionID, len(h.connections)) case client := <-h.unregister: h.mu.Lock() - if _, ok := h.clients[client]; ok { - delete(h.clients, client) + if _, ok := h.connections[client]; ok { + delete(h.connections, client) close(client.send) - log.Printf("Client %s disconnected from hub %s (%d remaining)", client.id, h.sessionID, len(h.clients)) + log.Printf("Client %s disconnected from hub %s (%d remaining)", client.id, h.sessionID, len(h.connections)) // When last client leaves, shutdown hub - if len(h.clients) == 0 { + if len(h.connections) == 0 { h.mu.Unlock() log.Printf("Session %s has no clients, shutting down hub", h.sessionID) h.shutdown() @@ -89,18 +89,18 @@ func (h *Hub) Run() { case event := <-h.events: h.lastActivity = time.Now() h.mu.RLock() - var slowClients []*Client - for client := range h.clients { + var slowConnections []*Connection + for connection := range h.connections { select { - case client.send <- event: - default: // when a send operation blocks (client consuming too slowly or reader goroutine died), discard the client - slowClients = append(slowClients, client) + case connection.send <- event: + default: // when a send operation blocks (connection consuming too slowly or reader goroutine died), discard the connection + slowConnections = append(slowConnections, connection) } } h.mu.RUnlock() - for _, client := range slowClients { - log.Printf("Client %s is slow; unregistering from hub %s", client.id, h.sessionID) - h.Unregister(client) + for _, connection := range slowConnections { + log.Printf("Connection %s is slow; unregistering from hub %s", connection.id, h.sessionID) + h.Unregister(connection) } case cmd := <-h.commands: @@ -111,12 +111,12 @@ func (h *Hub) Run() { } // Public APIs -func (h *Hub) Register(client *Client) { - h.register <- client +func (h *Hub) Register(connection *Connection) { + h.register <- connection } -func (h *Hub) Unregister(client *Client) { - h.unregister <- client +func (h *Hub) Unregister(connection *Connection) { + h.unregister <- connection } func (h *Hub) Broadcast(event Message) { diff --git a/internal/ws/server.go b/internal/ws/server.go index 317302d..d8e64dc 100644 --- a/internal/ws/server.go +++ b/internal/ws/server.go @@ -59,7 +59,7 @@ func (s *Server) serveWebSocket(w http.ResponseWriter, r *http.Request) { } hub := s.GetOrCreateHub(sessionID) - client := NewClient(conn, hub, r.RemoteAddr) + client := NewConnection(conn, hub, r.RemoteAddr) go client.ReadPump() go client.WritePump() diff --git a/internal/ws/ws_test.go b/internal/ws/ws_test.go index 833c55d..d9f2d48 100644 --- a/internal/ws/ws_test.go +++ b/internal/ws/ws_test.go @@ -63,7 +63,7 @@ var _ = Describe("Hub", func() { Expect(testHub.sessionID).To(Equal(sessionID)) Expect(testHub.idleTimeout).To(Equal(idleTimeout)) - Expect(testHub.clients).To(BeEmpty()) + Expect(testHub.connections).To(BeEmpty()) Expect(testHub.register).NotTo(BeNil()) Expect(testHub.unregister).NotTo(BeNil()) Expect(testHub.events).NotTo(BeNil()) @@ -71,47 +71,46 @@ var _ = Describe("Hub", func() { }) }) - Describe("RegisterClient", func() { - It("should register a client with hub", func() { + Describe("RegisterConnection", func() { + It("should register a connection with hub", func() { dialer := websocket.Dialer{} conn, _, err := dialer.Dial(wsURL, nil) Expect(err).NotTo(HaveOccurred()) defer func() { _ = conn.Close() }() - client := NewClient(conn, hub, "client-1") - hub.Register(client) + connection := NewConnection(conn, hub, "connection-1") + hub.Register(connection) time.Sleep(100 * time.Millisecond) hub.mu.RLock() - clientCount := len(hub.clients) + connectionCount := len(hub.connections) hub.mu.RUnlock() - Expect(clientCount).To(Equal(1)) + Expect(connectionCount).To(Equal(1)) }) }) - Describe("UnregisterClient", func() { - It("should unregister a client from hub", func() { + Describe("UnregisterConnection", func() { + It("should unregister a connection from hub", func() { dialer := websocket.Dialer{} conn, _, err := dialer.Dial(wsURL, nil) Expect(err).NotTo(HaveOccurred()) defer func() { _ = conn.Close() }() - client := NewClient(conn, hub, "client-1") - hub.Register(client) + connection := NewConnection(conn, hub, "connection-1") + hub.Register(connection) time.Sleep(50 * time.Millisecond) - hub.Unregister(client) - + hub.Unregister(connection) time.Sleep(100 * time.Millisecond) hub.mu.RLock() - clientCount := len(hub.clients) + connectionCount := len(hub.connections) hub.mu.RUnlock() - Expect(clientCount).To(Equal(0)) + Expect(connectionCount).To(Equal(0)) Expect(shutdownCalled.Load()).To(BeTrue()) }) }) @@ -152,7 +151,7 @@ var _ = Describe("Hub", func() { for { select { case <-ticker.C: - if hub.idleTimeout > 0 && len(hub.clients) == 0 { + if hub.idleTimeout > 0 && len(hub.connections) == 0 { if time.Since(hub.lastActivity) > hub.idleTimeout { hub.shutdown() done <- struct{}{} @@ -162,16 +161,16 @@ var _ = Describe("Hub", func() { case client := <-hub.register: hub.mu.Lock() - hub.clients[client] = struct{}{} + hub.connections[client] = struct{}{} hub.lastActivity = time.Now() hub.mu.Unlock() case client := <-hub.unregister: hub.mu.Lock() - if _, ok := hub.clients[client]; ok { - delete(hub.clients, client) + if _, ok := hub.connections[client]; ok { + delete(hub.connections, client) close(client.send) - if len(hub.clients) == 0 { + if len(hub.connections) == 0 { hub.mu.Unlock() hub.shutdown() done <- struct{}{} @@ -220,7 +219,7 @@ var _ = Describe("Hub", func() { }) }) -var _ = Describe("Client", func() { +var _ = Describe("Connection", func() { var ( hub *Hub server *httptest.Server @@ -245,19 +244,19 @@ var _ = Describe("Client", func() { } }) - Describe("NewClient", func() { + Describe("NewConnection", func() { It("should create a new client with correct properties", func() { dialer := websocket.Dialer{} conn, _, err := dialer.Dial(wsURL, nil) Expect(err).NotTo(HaveOccurred()) defer func() { _ = conn.Close() }() - client := NewClient(conn, hub, "client-1") + connection := NewConnection(conn, hub, "connection-1") - Expect(client.id).To(Equal("client-1")) - Expect(client.hub).To(Equal(hub)) - Expect(client.conn).To(Equal(conn)) - Expect(client.send).NotTo(BeNil()) + Expect(connection.id).To(Equal("connection-1")) + Expect(connection.hub).To(Equal(hub)) + Expect(connection.conn).To(Equal(conn)) + Expect(connection.send).NotTo(BeNil()) }) }) @@ -270,10 +269,10 @@ var _ = Describe("Client", func() { conn, _ := upgrader.Upgrade(w, r, nil) defer func() { _ = conn.Close() }() - client := NewClient(conn, hub, r.RemoteAddr) - hub.Register(client) + connection := NewConnection(conn, hub, r.RemoteAddr) + hub.Register(connection) - client.ReadPump() + connection.ReadPump() })) defer testServer.Close() @@ -314,7 +313,7 @@ var _ = Describe("Client", func() { go hub.Run() testServer, clientConn := upgradeAndDial(func(conn *websocket.Conn) { - client := NewClient(conn, hub, "test-client") + client := NewConnection(conn, hub, "test-client") hub.Register(client) go client.WritePump() @@ -352,12 +351,11 @@ var _ = Describe("Client", func() { }) defer testServer.Close() - client := NewClient(clientConn, nil, "client-1") - go client.WritePump() - - client.send <- Message{Type: string(EventStateUpdate), Data: json.RawMessage(`{"ok":true}`)} - close(client.send) + connection := NewConnection(clientConn, nil, "connection-1") + go connection.WritePump() + connection.send <- Message{Type: string(EventStateUpdate), Data: json.RawMessage(`{"ok":true}`)} + close(connection.send) Eventually(func() bool { select { case <-received: @@ -386,10 +384,10 @@ var _ = Describe("Client", func() { }) defer testServer.Close() - client := NewClient(clientConn, nil, "client-1") - go client.WritePump() + connection := NewConnection(clientConn, nil, "connection-1") + go connection.WritePump() - client.send <- Message{Type: string(EventStateUpdate), Data: json.RawMessage(`{"ok":true}`)} + connection.send <- Message{Type: string(EventStateUpdate), Data: json.RawMessage(`{"ok":true}`)} Eventually(func() bool { select { @@ -414,10 +412,10 @@ var _ = Describe("Client", func() { }) defer testServer.Close() - client := NewClient(clientConn, nil, "client-1") - go client.WritePump() + connection := NewConnection(clientConn, nil, "connection-1") + go connection.WritePump() - close(client.send) + close(connection.send) Eventually(func() bool { select { @@ -431,7 +429,7 @@ var _ = Describe("Client", func() { }) Describe("ConcurrentOperations", func() { - It("should handle concurrent client operations", func() { + It("should handle concurrent connection operations", func() { go hub.Run() testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -439,8 +437,8 @@ var _ = Describe("Client", func() { conn, _ := upgrader.Upgrade(w, r, nil) defer func() { _ = conn.Close() }() - client := NewClient(conn, hub, r.RemoteAddr) - hub.Register(client) + connection := NewConnection(conn, hub, r.RemoteAddr) + hub.Register(connection) var msg Message for { @@ -473,7 +471,7 @@ var _ = Describe("Client", func() { time.Sleep(200 * time.Millisecond) hub.mu.RLock() - clientCount := len(hub.clients) + clientCount := len(hub.connections) hub.mu.RUnlock() Expect(clientCount).To(BeNumerically(">=", 0)) @@ -489,8 +487,8 @@ var _ = Describe("Client", func() { conn, _ := upgrader.Upgrade(w, r, nil) defer func() { _ = conn.Close() }() - client := NewClient(conn, hub, r.RemoteAddr) - hub.Register(client) + connection := NewConnection(conn, hub, r.RemoteAddr) + hub.Register(connection) select {} })) @@ -674,7 +672,7 @@ var _ = Describe("Server", func() { Expect(hubCount).To(Equal(0)) }) - It("should create hub and register client", func() { + It("should create hub and register connection", func() { wsConfig := config.WebSocketConfig{ MaxSessions: 10, IdleTimeout: time.Minute, @@ -704,7 +702,7 @@ var _ = Describe("Server", func() { } hub.mu.RLock() defer hub.mu.RUnlock() - return len(hub.clients) + return len(hub.connections) }, 2*time.Second, 50*time.Millisecond).Should(BeNumerically(">=", 1)) }) }) From b57f12544a05912b5a08c251fb95fe408fb40bf2 Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Wed, 4 Feb 2026 18:31:48 +0000 Subject: [PATCH 12/36] chore: remove dead commented code --- pkg/client/client.go | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/pkg/client/client.go b/pkg/client/client.go index 20e47c3..5fbdec6 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -197,37 +197,3 @@ func (c *Client) Close() error { } return nil } - -// Example usage: -// func main() { -// serverAddr := flag.String("server", "localhost:8080", "Server address") -// sessionID := flag.String("session", "test-session", "Session ID") -// flag.Parse() - -// client := NewClient(*serverAddr, *sessionID) - -// // Connect to the server -// if err := client.Connect(); err != nil { -// log.Fatalf("Failed to connect: %v", err) -// } -// if err := client.Run(); err != nil { -// log.Fatalf("Failed to start client: %v", err) -// } -// defer func() { -// if err := client.Close(); err != nil { -// log.Printf("Client close error: %v", err) -// } -// }() - -// // Set up interrupt handler -// interrupt := make(chan os.Signal, 1) -// signal.Notify(interrupt, os.Interrupt) - -// // Wait for interrupt or server close -// select { -// case <-interrupt: -// log.Println("Interrupt signal received") -// case <-client.done: -// log.Println("Server closed connection") -// } -// } From 598ddd3d3a27fac9ee382beda44aea1146e5ae45 Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Wed, 4 Feb 2026 19:17:19 +0000 Subject: [PATCH 13/36] feat: restructure debugger to be owned by hub with command forwarding --- cmd/bingo/main.go | 19 +++++++++---- internal/ws/hub.go | 62 +++++++++++++++++++++++++++++++++++++++-- internal/ws/protocol.go | 13 +++++++-- internal/ws/server.go | 45 ++++++++++++++++++++++++------ internal/ws/ws_test.go | 9 +++--- 5 files changed, 126 insertions(+), 22 deletions(-) diff --git a/cmd/bingo/main.go b/cmd/bingo/main.go index fd4e7a2..3f56e70 100644 --- a/cmd/bingo/main.go +++ b/cmd/bingo/main.go @@ -2,9 +2,11 @@ package main import ( "log" + "os" + "os/signal" + "syscall" "github.com/bingosuite/bingo/config" - "github.com/bingosuite/bingo/internal/debugger" websocket "github.com/bingosuite/bingo/internal/ws" ) @@ -24,8 +26,15 @@ func main() { } }() - d := debugger.NewDebugger() - // TODO: server tells debugger how to start the debugging session by passing path to start with debug or pid to attach - go d.StartWithDebug("/workspaces/bingo/build/target/target") - <-d.EndDebugSession + // Set up signal handling for graceful shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + // Wait for shutdown signal + <-sigChan + log.Println("Received interrupt signal, shutting down gracefully...") + + // Graceful shutdown + server.Shutdown() + log.Println("Server shutdown complete, exiting") } diff --git a/internal/ws/hub.go b/internal/ws/hub.go index ccc23dc..11d4d7f 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -1,9 +1,12 @@ package ws import ( + "encoding/json" "log" "sync" "time" + + "github.com/bingosuite/bingo/internal/debugger" ) const ( @@ -29,10 +32,12 @@ type Hub struct { idleTimeout time.Duration lastActivity time.Time + debugger *debugger.Debugger + mu sync.RWMutex } -func NewHub(sessionID string, idleTimeout time.Duration) *Hub { +func NewHub(sessionID string, idleTimeout time.Duration, dbg *debugger.Debugger) *Hub { return &Hub{ sessionID: sessionID, connections: make(map[*Connection]struct{}), @@ -42,6 +47,7 @@ func NewHub(sessionID string, idleTimeout time.Duration) *Hub { commands: make(chan Message, commandBufferSize), idleTimeout: idleTimeout, lastActivity: time.Now(), + debugger: dbg, } } @@ -105,7 +111,11 @@ func (h *Hub) Run() { case cmd := <-h.commands: log.Printf("Hub %s command: %s", h.sessionID, cmd.Type) - //TODO: forward commands to debugger + h.handleCommand(cmd) + + case <-h.debugger.EndDebugSession: + log.Printf("Debugger signaled end of session %s, shutting down hub", h.sessionID) + h.shutdown() } } } @@ -132,3 +142,51 @@ func (h *Hub) shutdown() { h.onShutdown(h.sessionID) } } + +func (h *Hub) handleCommand(cmd Message) { + if h.debugger == nil { + log.Printf("No debugger attached to hub %s, ignoring command", h.sessionID) + return + } + + switch CommandType(cmd.Type) { + case CmdStartDebug: + var startDebugCmd StartDebugCmd + if err := json.Unmarshal(cmd.Data, &startDebugCmd); err != nil { + log.Printf("Failed to unmarshal startDebug command: %v", err) + return + } + log.Printf("Starting debug session for %s in session %s", startDebugCmd.TargetPath, h.sessionID) + go h.debugger.StartWithDebug(startDebugCmd.TargetPath) + + case CmdContinue: + var continueCmd ContinueCmd + if err := json.Unmarshal(cmd.Data, &continueCmd); err != nil { + log.Printf("Failed to unmarshal continue command: %v", err) + return + } + log.Printf("Forwarding continue command to debugger for session %s", h.sessionID) + h.debugger.Continue(h.debugger.DebugInfo.Target.PID) + + case CmdStepOver: + var stepOverCmd StepOverCmd + if err := json.Unmarshal(cmd.Data, &stepOverCmd); err != nil { + log.Printf("Failed to unmarshal stepOver command: %v", err) + return + } + log.Printf("Forwarding stepOver command to debugger for session %s", h.sessionID) + h.debugger.SingleStep(h.debugger.DebugInfo.Target.PID) + + case CmdExit: + var exitCmd ExitCmd + if err := json.Unmarshal(cmd.Data, &exitCmd); err != nil { + log.Printf("Failed to unmarshal exit command: %v", err) + return + } + log.Printf("Forwarding exit command to debugger for session %s", h.sessionID) + h.debugger.StopDebug() + + default: + log.Printf("Unknown command type: %s", cmd.Type) + } +} diff --git a/internal/ws/protocol.go b/internal/ws/protocol.go index 7c3cdc0..4de25f1 100644 --- a/internal/ws/protocol.go +++ b/internal/ws/protocol.go @@ -38,11 +38,18 @@ type StateUpdateEvent struct { type CommandType string const ( - CmdContinue CommandType = "continue" - CmdStepOver CommandType = "stepOver" - CmdExit CommandType = "exit" + CmdStartDebug CommandType = "startDebug" + CmdContinue CommandType = "continue" + CmdStepOver CommandType = "stepOver" + CmdExit CommandType = "exit" ) +type StartDebugCmd struct { + Type CommandType `json:"type"` + SessionID string `json:"sessionId"` + TargetPath string `json:"targetPath"` +} + type ContinueCmd struct { Type CommandType `json:"type"` SessionID string `json:"sessionId"` diff --git a/internal/ws/server.go b/internal/ws/server.go index d8e64dc..93ce237 100644 --- a/internal/ws/server.go +++ b/internal/ws/server.go @@ -6,14 +6,16 @@ import ( "sync" "github.com/bingosuite/bingo/config" + "github.com/bingosuite/bingo/internal/debugger" "github.com/gorilla/websocket" ) type Server struct { - addr string - hubs map[string]*Hub - config config.WebSocketConfig - mu sync.RWMutex + addr string + hubs map[string]*Hub + config config.WebSocketConfig + mu sync.RWMutex + KillServer chan bool } func NewServer(addr string, cfg *config.WebSocketConfig) *Server { @@ -25,9 +27,10 @@ func NewServer(addr string, cfg *config.WebSocketConfig) *Server { func newServerWithConfig(addr string, cfg config.WebSocketConfig) *Server { s := &Server{ - addr: addr, - hubs: make(map[string]*Hub), - config: cfg, + addr: addr, + hubs: make(map[string]*Hub), + config: cfg, + KillServer: make(chan bool), } http.HandleFunc("/ws/", s.serveWebSocket) return s @@ -81,7 +84,8 @@ func (s *Server) GetOrCreateHub(sessionID string) *Hub { return nil } - hub = NewHub(sessionID, s.config.IdleTimeout) + d := debugger.NewDebugger() + hub = NewHub(sessionID, s.config.IdleTimeout, d) hub.onShutdown = s.removeHub // Set callback for cleanup s.hubs[sessionID] = hub go hub.Run() @@ -97,3 +101,28 @@ func (s *Server) removeHub(sessionID string) { s.mu.Unlock() log.Printf("Removed hub for session: %s", sessionID) } + +func (s *Server) Shutdown() { + log.Printf("Shutting down server, closing %d hub(s)", len(s.hubs)) + + s.mu.Lock() + defer s.mu.Unlock() + + // Close all hubs + for sessionID, hub := range s.hubs { + log.Printf("Shutting down hub for session: %s", sessionID) + + // Close all connections in the hub + for c := range hub.connections { + close(c.send) + if err := c.conn.Close(); err != nil { + log.Printf("Error closing connection %s: %v", c.id, err) + panic(err) + } + } + + delete(s.hubs, sessionID) + } + + log.Println("All hubs and debuggers closed") +} diff --git a/internal/ws/ws_test.go b/internal/ws/ws_test.go index d9f2d48..f0bbec9 100644 --- a/internal/ws/ws_test.go +++ b/internal/ws/ws_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/bingosuite/bingo/config" + "github.com/bingosuite/bingo/internal/debugger" "github.com/gorilla/websocket" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -31,7 +32,7 @@ var _ = Describe("Hub", func() { ) BeforeEach(func() { - hub = NewHub("test-session", time.Minute) + hub = NewHub("test-session", time.Minute, debugger.NewDebugger()) shutdownCalled = &atomic.Bool{} hub.onShutdown = func(sessionID string) { shutdownCalled.Store(true) @@ -59,7 +60,7 @@ var _ = Describe("Hub", func() { sessionID := "test-session" idleTimeout := 5 * time.Minute - testHub := NewHub(sessionID, idleTimeout) + testHub := NewHub(sessionID, idleTimeout, debugger.NewDebugger()) Expect(testHub.sessionID).To(Equal(sessionID)) Expect(testHub.idleTimeout).To(Equal(idleTimeout)) @@ -136,7 +137,7 @@ var _ = Describe("Hub", func() { Describe("IdleTimeout", func() { It("should detect idle timeout and shutdown", func() { idleTimeout := 100 * time.Millisecond - hub := NewHub("test-session", idleTimeout) + hub := NewHub("test-session", idleTimeout, debugger.NewDebugger()) shutdownCalled := atomic.Bool{} hub.onShutdown = func(sessionID string) { @@ -227,7 +228,7 @@ var _ = Describe("Connection", func() { ) BeforeEach(func() { - hub = NewHub("test-session", time.Minute) + hub = NewHub("test-session", time.Minute, debugger.NewDebugger()) server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { upgrader := websocket.Upgrader{} From 5fe4bf5c7fcb423d0b79c745c1b09a7e713bf477 Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Tue, 17 Feb 2026 18:54:20 +0000 Subject: [PATCH 14/36] feat: getSessions endpoint --- internal/ws/server.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/ws/server.go b/internal/ws/server.go index 93ce237..77a7ea1 100644 --- a/internal/ws/server.go +++ b/internal/ws/server.go @@ -1,6 +1,7 @@ package ws import ( + "encoding/json" "log" "net/http" "sync" @@ -33,6 +34,7 @@ func newServerWithConfig(addr string, cfg config.WebSocketConfig) *Server { KillServer: make(chan bool), } http.HandleFunc("/ws/", s.serveWebSocket) + http.HandleFunc("/sessions", s.getSessions) return s } @@ -41,6 +43,22 @@ func (s *Server) Serve() error { return http.ListenAndServe(s.addr, nil) } +func (s *Server) getSessions(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + defer s.mu.RUnlock() + + sessions := make([]string, 0, len(s.hubs)) + for sessionID := range s.hubs { + sessions = append(sessions, sessionID) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(sessions); err != nil { + log.Printf("Error encoding sessions: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + func (s *Server) serveWebSocket(w http.ResponseWriter, r *http.Request) { upgrader := websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, From 028ce73aead6a38a47c183f1b577d9778be470c8 Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Tue, 17 Feb 2026 18:59:36 +0000 Subject: [PATCH 15/36] feat: create session id when none provided --- go.mod | 1 + go.sum | 2 ++ internal/ws/server.go | 8 +++----- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 3a15e1a..57686a8 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/goccy/go-yaml v1.18.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/google/uuid v1.6.0 github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kaptinlin/go-i18n v0.1.7 // indirect github.com/kaptinlin/jsonschema v0.4.14 // indirect diff --git a/go.sum b/go.sum index 15d41aa..05a66fc 100644 --- a/go.sum +++ b/go.sum @@ -55,6 +55,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= diff --git a/internal/ws/server.go b/internal/ws/server.go index 77a7ea1..97b55fc 100644 --- a/internal/ws/server.go +++ b/internal/ws/server.go @@ -8,6 +8,7 @@ import ( "github.com/bingosuite/bingo/config" "github.com/bingosuite/bingo/internal/debugger" + "github.com/google/uuid" "github.com/gorilla/websocket" ) @@ -72,11 +73,8 @@ func (s *Server) serveWebSocket(w http.ResponseWriter, r *http.Request) { sessionID := r.URL.Query().Get("session") if sessionID == "" { - log.Println("No session ID provided") - if err := conn.Close(); err != nil { - log.Printf("WebSocket close error: %v", err) - } - return + log.Println("No session ID provided, generating...") + sessionID = uuid.New().String() } hub := s.GetOrCreateHub(sessionID) From 3ce2bbcb70a48b7d4a344c037f9a9560498d3a8f Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Tue, 17 Feb 2026 19:17:21 +0000 Subject: [PATCH 16/36] feat: send ack with sessionID back to client on connect --- internal/ws/server.go | 30 ++++++++++++++++++++++++---- internal/ws/ws_test.go | 44 +++++++++++++++++++++++++++++------------- pkg/client/client.go | 30 +++++++++++++++++++++++++--- 3 files changed, 84 insertions(+), 20 deletions(-) diff --git a/internal/ws/server.go b/internal/ws/server.go index 97b55fc..81d6a57 100644 --- a/internal/ws/server.go +++ b/internal/ws/server.go @@ -2,6 +2,7 @@ package ws import ( "encoding/json" + "fmt" "log" "net/http" "sync" @@ -77,16 +78,37 @@ func (s *Server) serveWebSocket(w http.ResponseWriter, r *http.Request) { sessionID = uuid.New().String() } - hub := s.GetOrCreateHub(sessionID) + hub, err := s.GetOrCreateHub(sessionID) + if err != nil { + log.Printf("Unable to create hub for session %s: %v", sessionID, err) + if err := conn.Close(); err != nil { + log.Printf("WebSocket close error: %v", err) + } + return + } client := NewConnection(conn, hub, r.RemoteAddr) go client.ReadPump() go client.WritePump() hub.Register(client) + ack := SessionStartedEvent{ + Type: EventSessionStarted, + SessionID: sessionID, + PID: 0, + } + data, err := json.Marshal(ack) + if err != nil { + log.Printf("Failed to marshal sessionStarted: %v", err) + return + } + client.send <- Message{ + Type: string(EventSessionStarted), + Data: data, + } } -func (s *Server) GetOrCreateHub(sessionID string) *Hub { +func (s *Server) GetOrCreateHub(sessionID string) (*Hub, error) { s.mu.RLock() hub, exists := s.hubs[sessionID] s.mu.RUnlock() @@ -97,7 +119,7 @@ func (s *Server) GetOrCreateHub(sessionID string) *Hub { if s.config.MaxSessions > 0 && len(s.hubs) >= s.config.MaxSessions { s.mu.Unlock() log.Printf("Max sessions (%d) reached, rejecting session: %s", s.config.MaxSessions, sessionID) - return nil + return nil, fmt.Errorf("max sessions (%d) reached", s.config.MaxSessions) } d := debugger.NewDebugger() @@ -108,7 +130,7 @@ func (s *Server) GetOrCreateHub(sessionID string) *Hub { s.mu.Unlock() log.Printf("Created hub for session: %s", sessionID) } - return hub + return hub, nil } func (s *Server) removeHub(sessionID string) { diff --git a/internal/ws/ws_test.go b/internal/ws/ws_test.go index f0bbec9..d634c54 100644 --- a/internal/ws/ws_test.go +++ b/internal/ws/ws_test.go @@ -549,14 +549,17 @@ var _ = Describe("Server", func() { config: wsConfig, } - hub1 := server.GetOrCreateHub("session-1") + hub1, err := server.GetOrCreateHub("session-1") + Expect(err).NotTo(HaveOccurred()) Expect(hub1).NotTo(BeNil()) Expect(hub1.sessionID).To(Equal("session-1")) - hub2 := server.GetOrCreateHub("session-1") + hub2, err := server.GetOrCreateHub("session-1") + Expect(err).NotTo(HaveOccurred()) Expect(hub2).To(Equal(hub1)) - hub3 := server.GetOrCreateHub("session-2") + hub3, err := server.GetOrCreateHub("session-2") + Expect(err).NotTo(HaveOccurred()) Expect(hub3).NotTo(BeNil()) Expect(hub3).NotTo(Equal(hub1)) }) @@ -575,15 +578,18 @@ var _ = Describe("Server", func() { config: wsConfig, } - hub1 := server.GetOrCreateHub("session-1") + hub1, err := server.GetOrCreateHub("session-1") + Expect(err).NotTo(HaveOccurred()) hub1.onShutdown = server.removeHub Expect(hub1).NotTo(BeNil()) - hub2 := server.GetOrCreateHub("session-2") + hub2, err := server.GetOrCreateHub("session-2") + Expect(err).NotTo(HaveOccurred()) hub2.onShutdown = server.removeHub Expect(hub2).NotTo(BeNil()) - hub3 := server.GetOrCreateHub("session-3") + hub3, err := server.GetOrCreateHub("session-3") + Expect(err).To(HaveOccurred()) Expect(hub3).To(BeNil()) server.mu.RLock() @@ -606,7 +612,8 @@ var _ = Describe("Server", func() { config: wsConfig, } - hub := server.GetOrCreateHub("session-1") + hub, err := server.GetOrCreateHub("session-1") + Expect(err).NotTo(HaveOccurred()) Expect(hub).NotTo(BeNil()) server.removeHub("session-1") @@ -619,7 +626,7 @@ var _ = Describe("Server", func() { }) Describe("serveWebSocket", func() { - It("should reject connections without session", func() { + It("should create session and ack when session is missing", func() { wsConfig := config.WebSocketConfig{ MaxSessions: 10, IdleTimeout: time.Minute, @@ -638,14 +645,25 @@ var _ = Describe("Server", func() { wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/" conn, _, err := dialer.Dial(wsURL, nil) Expect(err).NotTo(HaveOccurred()) + + var msg Message + _ = conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + Expect(conn.ReadJSON(&msg)).To(Succeed()) + Expect(msg.Type).To(Equal(string(EventSessionStarted))) + + var started SessionStartedEvent + Expect(json.Unmarshal(msg.Data, &started)).To(Succeed()) + Expect(started.SessionID).NotTo(BeEmpty()) + _ = conn.Close() - time.Sleep(50 * time.Millisecond) + Eventually(func() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.hubs) + }, 2*time.Second, 50*time.Millisecond).Should(BeNumerically(">=", 1)) - s.mu.RLock() - hubCount := len(s.hubs) - s.mu.RUnlock() - Expect(hubCount).To(Equal(0)) + time.Sleep(50 * time.Millisecond) }) It("should return on upgrade error", func() { diff --git a/pkg/client/client.go b/pkg/client/client.go index 5fbdec6..e46f06f 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -17,6 +17,7 @@ type Client struct { conn *websocket.Conn send chan ws.Message done chan struct{} + sessionMu sync.RWMutex stateMu sync.RWMutex state ws.State } @@ -34,11 +35,12 @@ func NewClient(serverURL, sessionID string) *Client { func (c *Client) Connect() error { // Build WebSocket URL with session ID + sessionID := c.SessionID() u := url.URL{ Scheme: "ws", Host: c.serverURL, Path: "/ws/", - RawQuery: "session=" + c.sessionID, + RawQuery: "session=" + sessionID, } log.Printf("Connecting to %s", u.String()) @@ -104,6 +106,16 @@ func (c *Client) handleMessage(msg ws.Message) { switch ws.EventType(msg.Type) { case ws.EventSessionStarted: + var started ws.SessionStartedEvent + if err := unmarshalData(msg.Data, &started); err != nil { + log.Printf("Error parsing sessionStarted: %v", err) + return + } + if started.SessionID != "" { + c.setSessionID(started.SessionID) + log.Printf("Session ID set: %s", started.SessionID) + return + } log.Println("Debug session started") case ws.EventStateUpdate: @@ -154,7 +166,7 @@ func (c *Client) SendCommand(cmdType string, payload []byte) error { func (c *Client) Continue() error { cmd := ws.ContinueCmd{ Type: ws.CmdContinue, - SessionID: c.sessionID, + SessionID: c.SessionID(), } payload, err := marshalJSON(cmd) if err != nil { @@ -166,7 +178,7 @@ func (c *Client) Continue() error { func (c *Client) StepOver() error { cmd := ws.StepOverCmd{ Type: ws.CmdStepOver, - SessionID: c.sessionID, + SessionID: c.SessionID(), } payload, err := marshalJSON(cmd) if err != nil { @@ -185,6 +197,18 @@ func (c *Client) setState(state ws.State) { c.state = state } +func (c *Client) setSessionID(sessionID string) { + c.sessionMu.Lock() + defer c.sessionMu.Unlock() + c.sessionID = sessionID +} + +func (c *Client) SessionID() string { + c.sessionMu.RLock() + defer c.sessionMu.RUnlock() + return c.sessionID +} + func (c *Client) State() ws.State { c.stateMu.RLock() defer c.stateMu.RUnlock() From d12119304373bb23b0b268825232f223be071ab3 Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Tue, 17 Feb 2026 19:34:21 +0000 Subject: [PATCH 17/36] feat: wire debugger to hub --- internal/debugger/debugger.go | 66 +++++++++++++++-- internal/ws/hub.go | 130 ++++++++++++++++++++++++++++++++-- internal/ws/protocol.go | 10 +++ 3 files changed, 195 insertions(+), 11 deletions(-) diff --git a/internal/debugger/debugger.go b/internal/debugger/debugger.go index 05532ff..f5cab35 100644 --- a/internal/debugger/debugger.go +++ b/internal/debugger/debugger.go @@ -22,12 +22,32 @@ type Debugger struct { DebugInfo debuginfo.DebugInfo Breakpoints map[uint64][]byte EndDebugSession chan bool + + // Communication with hub + BreakpointHit chan BreakpointEvent + DebugCommand chan DebugCommand +} + +// BreakpointEvent represents a breakpoint hit event +type BreakpointEvent struct { + PID int `json:"pid"` + Filename string `json:"filename"` + Line int `json:"line"` + Function string `json:"function"` +} + +// DebugCommand represents commands that can be sent to the debugger +type DebugCommand struct { + Type string `json:"type"` // "continue", "step", "quit" + Data interface{} `json:"data,omitempty"` } func NewDebugger() *Debugger { return &Debugger{ Breakpoints: make(map[uint64][]byte), EndDebugSession: make(chan bool, 1), + BreakpointHit: make(chan BreakpointEvent, 1), + DebugCommand: make(chan DebugCommand, 1), } } @@ -220,7 +240,6 @@ func (d *Debugger) initialBreakpointHit() { // TODO: NUKE, forward necessary information to the server instead log.Println("INITIAL BREAKPOINT HIT") - //TODO: tell server we hit the initial breakpoint and need to know what to do (continue, set bp, step over, quit) if err := d.SetBreakpoint(11); err != nil { log.Printf("Failed to set breakpoint: %v", err) panic(err) @@ -237,9 +256,46 @@ func (d *Debugger) initialBreakpointHit() { } func (d *Debugger) breakpointHit(pid int) { - // TODO: NUKE, forward necessary information to the server instead - log.Println("BREAKPOINT HIT") + // Get register information to determine location + var regs syscall.PtraceRegs + if err := syscall.PtraceGetRegs(pid, ®s); err != nil { + log.Printf("Failed to get registers: %v", err) + return + } - //TODO: select with channels from server that tell the debugger whether to continue, single step, set breakpoint or quite - d.Continue(pid) + // Get location information + filename, line, fn := d.DebugInfo.PCToLine(regs.Rip - 1) + + // Create breakpoint event + event := BreakpointEvent{ + PID: pid, + Filename: filename, + Line: line, + Function: fn.Name, + } + + // Send breakpoint hit event to hub + log.Printf("Breakpoint hit at %s:%d in %s, waiting for command", filename, line, fn.Name) + d.BreakpointHit <- event + + // Wait for command from hub + select { + case cmd := <-d.DebugCommand: + log.Printf("Received command: %s", cmd.Type) + switch cmd.Type { + case "continue": + d.Continue(pid) + case "step": + d.SingleStep(pid) + case "quit": + d.StopDebug() + return + default: + log.Printf("Unknown command: %s", cmd.Type) + d.Continue(pid) // Default to continue + } + case <-d.EndDebugSession: + log.Println("Debug session ending, stopping breakpoint handler") + return + } } diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 11d4d7f..e013c2f 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -55,6 +55,11 @@ func (h *Hub) Run() { ticker := time.NewTicker(hubTickerInterval) defer ticker.Stop() + // Start listening for debugger events if debugger is attached + if h.debugger != nil { + go h.listenForDebuggerEvents() + } + for { select { case <-ticker.C: @@ -143,6 +148,64 @@ func (h *Hub) shutdown() { } } +// Listen for events from the debugger (breakpoint hits) +func (h *Hub) listenForDebuggerEvents() { + for { + select { + case bpEvent := <-h.debugger.BreakpointHit: + log.Printf("Received breakpoint event from debugger: %s:%d", bpEvent.Filename, bpEvent.Line) + + // Create and send breakpoint hit event to all clients + event := BreakpointHitEvent{ + Type: EventBreakpointHit, + SessionID: h.sessionID, + PID: bpEvent.PID, + Filename: bpEvent.Filename, + Line: bpEvent.Line, + Function: bpEvent.Function, + } + + eventData, err := json.Marshal(event) + if err != nil { + log.Printf("Failed to marshal breakpoint event: %v", err) + continue + } + + message := Message{ + Type: string(EventBreakpointHit), + Data: eventData, + } + + h.Broadcast(message) + + // Also send state update to indicate we're at a breakpoint + stateEvent := StateUpdateEvent{ + Type: EventStateUpdate, + SessionID: h.sessionID, + NewState: StateBreakpoint, + } + + stateData, err := json.Marshal(stateEvent) + if err != nil { + log.Printf("Failed to marshal state update event: %v", err) + continue + } + + stateMessage := Message{ + Type: string(EventStateUpdate), + Data: stateData, + } + + h.Broadcast(stateMessage) + + case <-h.debugger.EndDebugSession: + log.Println("Debugger event listener ending") + return + } + } +} + +// Forward commands from client to debugger func (h *Hub) handleCommand(cmd Message) { if h.debugger == nil { log.Printf("No debugger attached to hub %s, ignoring command", h.sessionID) @@ -165,8 +228,20 @@ func (h *Hub) handleCommand(cmd Message) { log.Printf("Failed to unmarshal continue command: %v", err) return } - log.Printf("Forwarding continue command to debugger for session %s", h.sessionID) - h.debugger.Continue(h.debugger.DebugInfo.Target.PID) + log.Printf("Sending continue command to debugger for session %s", h.sessionID) + + // Send executing state update + h.sendStateUpdate(StateExecuting) + + // Send command to debugger + debugCmd := debugger.DebugCommand{ + Type: "continue", + } + select { + case h.debugger.DebugCommand <- debugCmd: + default: + log.Printf("Failed to send continue command to debugger - channel full") + } case CmdStepOver: var stepOverCmd StepOverCmd @@ -174,8 +249,20 @@ func (h *Hub) handleCommand(cmd Message) { log.Printf("Failed to unmarshal stepOver command: %v", err) return } - log.Printf("Forwarding stepOver command to debugger for session %s", h.sessionID) - h.debugger.SingleStep(h.debugger.DebugInfo.Target.PID) + log.Printf("Sending step command to debugger for session %s", h.sessionID) + + // Send executing state update + h.sendStateUpdate(StateExecuting) + + // Send command to debugger + debugCmd := debugger.DebugCommand{ + Type: "step", + } + select { + case h.debugger.DebugCommand <- debugCmd: + default: + log.Printf("Failed to send step command to debugger - channel full") + } case CmdExit: var exitCmd ExitCmd @@ -183,10 +270,41 @@ func (h *Hub) handleCommand(cmd Message) { log.Printf("Failed to unmarshal exit command: %v", err) return } - log.Printf("Forwarding exit command to debugger for session %s", h.sessionID) - h.debugger.StopDebug() + log.Printf("Sending quit command to debugger for session %s", h.sessionID) + + // Send command to debugger + debugCmd := debugger.DebugCommand{ + Type: "quit", + } + select { + case h.debugger.DebugCommand <- debugCmd: + default: + log.Printf("Failed to send quit command to debugger - channel full") + } default: log.Printf("Unknown command type: %s", cmd.Type) } } + +// Helper method to send state updates +func (h *Hub) sendStateUpdate(state State) { + stateEvent := StateUpdateEvent{ + Type: EventStateUpdate, + SessionID: h.sessionID, + NewState: state, + } + + stateData, err := json.Marshal(stateEvent) + if err != nil { + log.Printf("Failed to marshal state update event: %v", err) + return + } + + stateMessage := Message{ + Type: string(EventStateUpdate), + Data: stateData, + } + + h.Broadcast(stateMessage) +} diff --git a/internal/ws/protocol.go b/internal/ws/protocol.go index 4de25f1..5a8f11b 100644 --- a/internal/ws/protocol.go +++ b/internal/ws/protocol.go @@ -20,6 +20,7 @@ type EventType string const ( EventSessionStarted EventType = "sessionStarted" EventStateUpdate EventType = "stateUpdate" + EventBreakpointHit EventType = "breakpointHit" ) type SessionStartedEvent struct { @@ -34,6 +35,15 @@ type StateUpdateEvent struct { NewState State `json:"newState"` } +type BreakpointHitEvent struct { + Type EventType `json:"type"` + SessionID string `json:"sessionId"` + PID int `json:"pid"` + Filename string `json:"filename"` + Line int `json:"line"` + Function string `json:"function"` +} + // Command messages (client -> server) type CommandType string From b3eb2e2170a254e1d525d41bbe846135ee2305db Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Tue, 17 Feb 2026 19:50:11 +0000 Subject: [PATCH 18/36] feat: adds wiring for setting breakpoints --- internal/debugger/debugger.go | 92 +++++++++++++++++++++++++++-------- internal/ws/hub.go | 45 +++++++++++++++++ internal/ws/protocol.go | 29 ++++++++--- 3 files changed, 138 insertions(+), 28 deletions(-) diff --git a/internal/debugger/debugger.go b/internal/debugger/debugger.go index f5cab35..f2f5dc9 100644 --- a/internal/debugger/debugger.go +++ b/internal/debugger/debugger.go @@ -24,8 +24,9 @@ type Debugger struct { EndDebugSession chan bool // Communication with hub - BreakpointHit chan BreakpointEvent - DebugCommand chan DebugCommand + BreakpointHit chan BreakpointEvent + InitialBreakpointHit chan InitialBreakpointEvent + DebugCommand chan DebugCommand } // BreakpointEvent represents a breakpoint hit event @@ -36,18 +37,24 @@ type BreakpointEvent struct { Function string `json:"function"` } +// InitialBreakpointEvent represents the initial breakpoint hit when debugging starts +type InitialBreakpointEvent struct { + PID int `json:"pid"` +} + // DebugCommand represents commands that can be sent to the debugger type DebugCommand struct { - Type string `json:"type"` // "continue", "step", "quit" + Type string `json:"type"` // "continue", "step", "quit", "setBreakpoint" Data interface{} `json:"data,omitempty"` } func NewDebugger() *Debugger { return &Debugger{ - Breakpoints: make(map[uint64][]byte), - EndDebugSession: make(chan bool, 1), - BreakpointHit: make(chan BreakpointEvent, 1), - DebugCommand: make(chan DebugCommand, 1), + Breakpoints: make(map[uint64][]byte), + EndDebugSession: make(chan bool, 1), + BreakpointHit: make(chan BreakpointEvent, 1), + InitialBreakpointHit: make(chan InitialBreakpointEvent, 1), + DebugCommand: make(chan DebugCommand, 1), } } @@ -237,21 +244,52 @@ func (d *Debugger) debug() { } func (d *Debugger) initialBreakpointHit() { - // TODO: NUKE, forward necessary information to the server instead - log.Println("INITIAL BREAKPOINT HIT") - - if err := d.SetBreakpoint(11); err != nil { - log.Printf("Failed to set breakpoint: %v", err) - panic(err) - } - if err := d.SetBreakpoint(13); err != nil { - log.Printf("Failed to set breakpoint: %v", err) - panic(err) + // Create initial breakpoint event + event := InitialBreakpointEvent{ + PID: d.DebugInfo.Target.PID, } - // When initial breakpoint is hit, resume execution like this instead of d.Continue() after receiving continue from the server - if err := syscall.PtraceCont(d.DebugInfo.Target.PID, 0); err != nil { - log.Printf("Failed to resume target execution: %v", err) - panic(err) + + // Send initial breakpoint hit event to hub + log.Println("Initial breakpoint hit, debugger ready for commands") + d.InitialBreakpointHit <- event + + // Wait for commands from hub (typically to set breakpoints) + for { + select { + case cmd := <-d.DebugCommand: + log.Printf("Initial state - received command: %s", cmd.Type) + switch cmd.Type { + case "setBreakpoint": + if data, ok := cmd.Data.(map[string]interface{}); ok { + if line, ok := data["line"].(float64); ok { // JSON numbers are float64 + if err := d.SetBreakpoint(int(line)); err != nil { + log.Printf("Failed to set breakpoint at line %d: %v", int(line), err) + } else { + log.Printf("Breakpoint set at line %d", int(line)) + } + } + } + case "continue": + log.Println("Continuing from initial breakpoint") + if err := syscall.PtraceCont(d.DebugInfo.Target.PID, 0); err != nil { + log.Printf("Failed to resume target execution: %v", err) + panic(err) + } + return // Exit initial breakpoint handling + case "step": + log.Println("Stepping from initial breakpoint") + d.SingleStep(d.DebugInfo.Target.PID) + return // Exit initial breakpoint handling + case "quit": + d.StopDebug() + return + default: + log.Printf("Unknown command during initial breakpoint: %s", cmd.Type) + } + case <-d.EndDebugSession: + log.Println("Debug session ending during initial breakpoint") + return + } } } @@ -287,6 +325,18 @@ func (d *Debugger) breakpointHit(pid int) { d.Continue(pid) case "step": d.SingleStep(pid) + case "setBreakpoint": + if data, ok := cmd.Data.(map[string]interface{}); ok { + if line, ok := data["line"].(float64); ok { // JSON numbers are float64 + if err := d.SetBreakpoint(int(line)); err != nil { + log.Printf("Failed to set breakpoint at line %d: %v", int(line), err) + } else { + log.Printf("Breakpoint set at line %d while at breakpoint", int(line)) + } + } + } + // After setting breakpoint, continue waiting for next command + d.Continue(pid) case "quit": d.StopDebug() return diff --git a/internal/ws/hub.go b/internal/ws/hub.go index e013c2f..5ae5a0b 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -198,6 +198,29 @@ func (h *Hub) listenForDebuggerEvents() { h.Broadcast(stateMessage) + case initialBpEvent := <-h.debugger.InitialBreakpointHit: + log.Printf("Received initial breakpoint event from debugger for PID %d", initialBpEvent.PID) + + // Create and send initial breakpoint event to all clients + event := InitialBreakpointEvent{ + Type: EventInitialBreakpoint, + SessionID: h.sessionID, + PID: initialBpEvent.PID, + } + + eventData, err := json.Marshal(event) + if err != nil { + log.Printf("Failed to marshal initial breakpoint event: %v", err) + continue + } + + message := Message{ + Type: string(EventInitialBreakpoint), + Data: eventData, + } + + h.Broadcast(message) + case <-h.debugger.EndDebugSession: log.Println("Debugger event listener ending") return @@ -264,6 +287,28 @@ func (h *Hub) handleCommand(cmd Message) { log.Printf("Failed to send step command to debugger - channel full") } + case CmdSetBreakpoint: + var setBreakpointCmd SetBreakpointCmd + if err := json.Unmarshal(cmd.Data, &setBreakpointCmd); err != nil { + log.Printf("Failed to unmarshal setBreakpoint command: %v", err) + return + } + log.Printf("Sending set breakpoint command for line %d in session %s", setBreakpointCmd.Line, h.sessionID) + + // Send command to debugger + debugCmd := debugger.DebugCommand{ + Type: "setBreakpoint", + Data: map[string]interface{}{ + "line": setBreakpointCmd.Line, + "filename": setBreakpointCmd.Filename, + }, + } + select { + case h.debugger.DebugCommand <- debugCmd: + default: + log.Printf("Failed to send set breakpoint command to debugger - channel full") + } + case CmdExit: var exitCmd ExitCmd if err := json.Unmarshal(cmd.Data, &exitCmd); err != nil { diff --git a/internal/ws/protocol.go b/internal/ws/protocol.go index 5a8f11b..401e0f1 100644 --- a/internal/ws/protocol.go +++ b/internal/ws/protocol.go @@ -18,9 +18,10 @@ const ( type EventType string const ( - EventSessionStarted EventType = "sessionStarted" - EventStateUpdate EventType = "stateUpdate" - EventBreakpointHit EventType = "breakpointHit" + EventSessionStarted EventType = "sessionStarted" + EventStateUpdate EventType = "stateUpdate" + EventBreakpointHit EventType = "breakpointHit" + EventInitialBreakpoint EventType = "initialBreakpoint" ) type SessionStartedEvent struct { @@ -44,14 +45,21 @@ type BreakpointHitEvent struct { Function string `json:"function"` } +type InitialBreakpointEvent struct { + Type EventType `json:"type"` + SessionID string `json:"sessionId"` + PID int `json:"pid"` +} + // Command messages (client -> server) type CommandType string const ( - CmdStartDebug CommandType = "startDebug" - CmdContinue CommandType = "continue" - CmdStepOver CommandType = "stepOver" - CmdExit CommandType = "exit" + CmdStartDebug CommandType = "startDebug" + CmdSetBreakpoint CommandType = "setBreakpoint" + CmdContinue CommandType = "continue" + CmdStepOver CommandType = "stepOver" + CmdExit CommandType = "exit" ) type StartDebugCmd struct { @@ -70,6 +78,13 @@ type StepOverCmd struct { SessionID string `json:"sessionId"` } +type SetBreakpointCmd struct { + Type CommandType `json:"type"` + SessionID string `json:"sessionId"` + Filename string `json:"filename"` + Line int `json:"line"` +} + type ExitCmd struct { Type CommandType `json:"type"` SessionID string `json:"sessionId"` From f344c20f12d5791f4766296d4a11dcd0c60f95c0 Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Tue, 17 Feb 2026 20:00:37 +0000 Subject: [PATCH 19/36] feat: send state update on initial breakpoint --- internal/ws/hub.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 5ae5a0b..a7db9fa 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -221,6 +221,26 @@ func (h *Hub) listenForDebuggerEvents() { h.Broadcast(message) + // Also send state update to indicate we're at a breakpoint + stateEvent := StateUpdateEvent{ + Type: EventStateUpdate, + SessionID: h.sessionID, + NewState: StateBreakpoint, + } + + stateData, err := json.Marshal(stateEvent) + if err != nil { + log.Printf("Failed to marshal state update event: %v", err) + continue + } + + stateMessage := Message{ + Type: string(EventStateUpdate), + Data: stateData, + } + + h.Broadcast(stateMessage) + case <-h.debugger.EndDebugSession: log.Println("Debugger event listener ending") return From e1efef96a050507ff63e68403b82f1fff8a65f44 Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Tue, 17 Feb 2026 23:09:11 +0000 Subject: [PATCH 20/36] fix: change make to just in lefthook.yml --- lefthook.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lefthook.yml b/lefthook.yml index 2380ebe..66199e6 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -32,7 +32,7 @@ pre-push: run: go vet ./cmd/bingo build: - run: make build + run: just build commit-msg: commands: From c90befa098ccd17aae1b18a136cb83ec888b2423 Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Tue, 17 Feb 2026 20:04:33 +0000 Subject: [PATCH 21/36] fix: log when parsing failed --- internal/debugger/debugger.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/debugger/debugger.go b/internal/debugger/debugger.go index f2f5dc9..8d9268d 100644 --- a/internal/debugger/debugger.go +++ b/internal/debugger/debugger.go @@ -265,9 +265,13 @@ func (d *Debugger) initialBreakpointHit() { if err := d.SetBreakpoint(int(line)); err != nil { log.Printf("Failed to set breakpoint at line %d: %v", int(line), err) } else { - log.Printf("Breakpoint set at line %d", int(line)) + log.Printf("Breakpoint set at line %d, waiting for next command", int(line)) } + } else { + log.Printf("Invalid setBreakpoint command: line field missing or wrong type") } + } else { + log.Printf("Invalid setBreakpoint command: data field missing or wrong format") } case "continue": log.Println("Continuing from initial breakpoint") From bed02f02e4ccbc932c87d0949004360d70fad66e Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Tue, 17 Feb 2026 22:58:45 +0000 Subject: [PATCH 22/36] wip: bruh --- cmd/bingo-client/main.go | 125 +++++++++++++++++++++++++++++++++++++++ pkg/client/client.go | 56 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 cmd/bingo-client/main.go diff --git a/cmd/bingo-client/main.go b/cmd/bingo-client/main.go new file mode 100644 index 0000000..0622902 --- /dev/null +++ b/cmd/bingo-client/main.go @@ -0,0 +1,125 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "log" + "os" + "strconv" + "strings" + + "github.com/bingosuite/bingo/config" + "github.com/bingosuite/bingo/pkg/client" +) + +func main() { + cfg, err := config.Load("config/config.yml") + if err != nil { + log.Printf("Failed to load config: %v", err) + } + + defaultAddr := "localhost:8080" + if cfg != nil && cfg.Server.Addr != "" { + if strings.HasPrefix(cfg.Server.Addr, ":") { + defaultAddr = "localhost" + cfg.Server.Addr + } else { + defaultAddr = cfg.Server.Addr + } + } + + server := flag.String("server", defaultAddr, "WebSocket server host:port") + session := flag.String("session", "", "Existing session ID (optional)") + flag.Parse() + + c := client.NewClient(*server, *session) + if err := c.Connect(); err != nil { + log.Fatalf("Failed to connect: %v", err) + } + if err := c.Run(); err != nil { + log.Fatalf("Failed to start client: %v", err) + } + + log.Println("Connected. Commands: start , c=continue, s=step, b= , state, q=quit") + scanner := bufio.NewScanner(os.Stdin) + for { + fmt.Printf("[%s] > ", c.State()) + if !scanner.Scan() { + break + } + raw := strings.TrimSpace(scanner.Text()) + input := strings.ToLower(raw) + fields := strings.Fields(raw) + var cmdErr error + switch input { + case "c", "continue": + cmdErr = c.Continue() + case "s", "step", "stepover": + cmdErr = c.StepOver() + case "state": + fmt.Printf("state=%s session=%s\n", c.State(), c.SessionID()) + case "q", "quit", "exit": + _ = c.Close() + return + case "": + continue + default: + if len(fields) > 0 && strings.EqualFold(fields[0], "start") { + cmdErr = handleStartCommand(c, fields) + if cmdErr != nil { + fmt.Println(cmdErr.Error()) + cmdErr = nil + break + } + break + } + cmdErr = handleBreakpointCommand(c, raw) + if cmdErr != nil { + fmt.Println(cmdErr.Error()) + cmdErr = nil + } + } + if cmdErr != nil { + log.Printf("Command error: %v", cmdErr) + } + } + + if err := scanner.Err(); err != nil { + log.Printf("Stdin error: %v", err) + } + _ = c.Close() +} + +func handleBreakpointCommand(c *client.Client, raw string) error { + fields := strings.Fields(raw) + if len(fields) == 0 { + return nil + } + cmd := strings.ToLower(fields[0]) + if cmd != "b" && cmd != "break" && cmd != "breakpoint" { + return fmt.Errorf("unknown command") + } + if len(fields) < 2 || len(fields) > 3 { + return fmt.Errorf("usage: b or b ") + } + filename := "" + lineStr := "" + if len(fields) == 2 { + lineStr = fields[1] + } else { + filename = fields[1] + lineStr = fields[2] + } + line, err := strconv.Atoi(lineStr) + if err != nil || line <= 0 { + return fmt.Errorf("invalid line number") + } + return c.SetBreakpoint(filename, line) +} + +func handleStartCommand(c *client.Client, fields []string) error { + if len(fields) != 2 { + return fmt.Errorf("usage: start ") + } + return c.StartDebug(fields[1]) +} diff --git a/pkg/client/client.go b/pkg/client/client.go index e46f06f..6519c92 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -127,6 +127,24 @@ func (c *Client) handleMessage(msg ws.Message) { c.setState(update.NewState) log.Printf("State updated: %s", update.NewState) + case ws.EventBreakpointHit: + var hit ws.BreakpointHitEvent + if err := unmarshalData(msg.Data, &hit); err != nil { + log.Printf("Error parsing breakpointHit: %v", err) + return + } + c.setState(ws.StateBreakpoint) + log.Printf("Breakpoint hit at %s:%d in %s", hit.Filename, hit.Line, hit.Function) + + case ws.EventInitialBreakpoint: + var initial ws.InitialBreakpointEvent + if err := unmarshalData(msg.Data, &initial); err != nil { + log.Printf("Error parsing initialBreakpoint: %v", err) + return + } + c.setState(ws.StateBreakpoint) + log.Printf("Initial breakpoint hit (pid=%d)", initial.PID) + default: log.Printf("Unknown message type: %s", msg.Type) } @@ -187,6 +205,44 @@ func (c *Client) StepOver() error { return c.SendCommand(string(ws.CmdStepOver), payload) } +func (c *Client) StartDebug(targetPath string) error { + cmd := ws.StartDebugCmd{ + Type: ws.CmdStartDebug, + SessionID: c.SessionID(), + TargetPath: targetPath, + } + payload, err := marshalJSON(cmd) + if err != nil { + return err + } + msg := ws.Message{ + Type: string(ws.CmdStartDebug), + Data: payload, + } + + select { + case c.send <- msg: + log.Printf("Queued command: %s", msg.Type) + return nil + case <-c.done: + return fmt.Errorf("connection closed") + } +} + +func (c *Client) SetBreakpoint(filename string, line int) error { + cmd := ws.SetBreakpointCmd{ + Type: ws.CmdSetBreakpoint, + SessionID: c.SessionID(), + Filename: filename, + Line: line, + } + payload, err := marshalJSON(cmd) + if err != nil { + return err + } + return c.SendCommand(string(ws.CmdSetBreakpoint), payload) +} + func marshalJSON(v any) ([]byte, error) { return json.Marshal(v) } From 982ac37e1f40e6cab0d0fa9d5fde9119f02a5b3c Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Tue, 17 Feb 2026 23:23:09 +0000 Subject: [PATCH 23/36] chore: improve logging --- internal/debugger/debugger.go | 2 +- internal/ws/hub.go | 32 ++++++++++++++++++++------------ pkg/client/client.go | 15 +++++++++++---- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/internal/debugger/debugger.go b/internal/debugger/debugger.go index 8d9268d..08330f9 100644 --- a/internal/debugger/debugger.go +++ b/internal/debugger/debugger.go @@ -261,7 +261,7 @@ func (d *Debugger) initialBreakpointHit() { switch cmd.Type { case "setBreakpoint": if data, ok := cmd.Data.(map[string]interface{}); ok { - if line, ok := data["line"].(float64); ok { // JSON numbers are float64 + if line, ok := data["line"].(int); ok { // JSON numbers are float64 if err := d.SetBreakpoint(int(line)); err != nil { log.Printf("Failed to set breakpoint at line %d: %v", int(line), err) } else { diff --git a/internal/ws/hub.go b/internal/ws/hub.go index a7db9fa..7a8c48a 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -153,7 +153,7 @@ func (h *Hub) listenForDebuggerEvents() { for { select { case bpEvent := <-h.debugger.BreakpointHit: - log.Printf("Received breakpoint event from debugger: %s:%d", bpEvent.Filename, bpEvent.Line) + log.Printf("[Debugger Event] Breakpoint hit at %s:%d in %s", bpEvent.Filename, bpEvent.Line, bpEvent.Function) // Create and send breakpoint hit event to all clients event := BreakpointHitEvent{ @@ -199,7 +199,7 @@ func (h *Hub) listenForDebuggerEvents() { h.Broadcast(stateMessage) case initialBpEvent := <-h.debugger.InitialBreakpointHit: - log.Printf("Received initial breakpoint event from debugger for PID %d", initialBpEvent.PID) + log.Printf("[Debugger Event] Initial breakpoint hit (PID: %d, session: %s)", initialBpEvent.PID, h.sessionID) // Create and send initial breakpoint event to all clients event := InitialBreakpointEvent{ @@ -222,6 +222,7 @@ func (h *Hub) listenForDebuggerEvents() { h.Broadcast(message) // Also send state update to indicate we're at a breakpoint + log.Printf("[State Change] Transitioning to breakpoint state (session: %s)", h.sessionID) stateEvent := StateUpdateEvent{ Type: EventStateUpdate, SessionID: h.sessionID, @@ -262,7 +263,7 @@ func (h *Hub) handleCommand(cmd Message) { log.Printf("Failed to unmarshal startDebug command: %v", err) return } - log.Printf("Starting debug session for %s in session %s", startDebugCmd.TargetPath, h.sessionID) + log.Printf("[Command] StartDebug received: %s (session: %s)", startDebugCmd.TargetPath, h.sessionID) go h.debugger.StartWithDebug(startDebugCmd.TargetPath) case CmdContinue: @@ -271,7 +272,8 @@ func (h *Hub) handleCommand(cmd Message) { log.Printf("Failed to unmarshal continue command: %v", err) return } - log.Printf("Sending continue command to debugger for session %s", h.sessionID) + log.Printf("[Command] Continue received (session: %s)", h.sessionID) + log.Printf("[State Change] Transitioning to executing state (session: %s)", h.sessionID) // Send executing state update h.sendStateUpdate(StateExecuting) @@ -282,8 +284,9 @@ func (h *Hub) handleCommand(cmd Message) { } select { case h.debugger.DebugCommand <- debugCmd: + log.Printf("[Command] Continue command sent to debugger (session: %s)", h.sessionID) default: - log.Printf("Failed to send continue command to debugger - channel full") + log.Printf("[Error] Failed to send continue command to debugger - channel full") } case CmdStepOver: @@ -292,7 +295,8 @@ func (h *Hub) handleCommand(cmd Message) { log.Printf("Failed to unmarshal stepOver command: %v", err) return } - log.Printf("Sending step command to debugger for session %s", h.sessionID) + log.Printf("[Command] StepOver received (session: %s)", h.sessionID) + log.Printf("[State Change] Transitioning to executing state (session: %s)", h.sessionID) // Send executing state update h.sendStateUpdate(StateExecuting) @@ -303,8 +307,9 @@ func (h *Hub) handleCommand(cmd Message) { } select { case h.debugger.DebugCommand <- debugCmd: + log.Printf("[Command] StepOver command sent to debugger (session: %s)", h.sessionID) default: - log.Printf("Failed to send step command to debugger - channel full") + log.Printf("[Error] Failed to send step command to debugger - channel full") } case CmdSetBreakpoint: @@ -313,7 +318,7 @@ func (h *Hub) handleCommand(cmd Message) { log.Printf("Failed to unmarshal setBreakpoint command: %v", err) return } - log.Printf("Sending set breakpoint command for line %d in session %s", setBreakpointCmd.Line, h.sessionID) + log.Printf("[Command] SetBreakpoint received: %s:%d (session: %s)", setBreakpointCmd.Filename, setBreakpointCmd.Line, h.sessionID) // Send command to debugger debugCmd := debugger.DebugCommand{ @@ -325,8 +330,9 @@ func (h *Hub) handleCommand(cmd Message) { } select { case h.debugger.DebugCommand <- debugCmd: + log.Printf("[Command] SetBreakpoint command sent to debugger (session: %s)", h.sessionID) default: - log.Printf("Failed to send set breakpoint command to debugger - channel full") + log.Printf("[Error] Failed to send set breakpoint command to debugger - channel full") } case CmdExit: @@ -335,7 +341,7 @@ func (h *Hub) handleCommand(cmd Message) { log.Printf("Failed to unmarshal exit command: %v", err) return } - log.Printf("Sending quit command to debugger for session %s", h.sessionID) + log.Printf("[Command] Exit received (session: %s)", h.sessionID) // Send command to debugger debugCmd := debugger.DebugCommand{ @@ -343,17 +349,19 @@ func (h *Hub) handleCommand(cmd Message) { } select { case h.debugger.DebugCommand <- debugCmd: + log.Printf("[Command] Exit command sent to debugger (session: %s)", h.sessionID) default: - log.Printf("Failed to send quit command to debugger - channel full") + log.Printf("[Error] Failed to send quit command to debugger - channel full") } default: - log.Printf("Unknown command type: %s", cmd.Type) + log.Printf("[Error] Unknown command type: %s", cmd.Type) } } // Helper method to send state updates func (h *Hub) sendStateUpdate(state State) { + log.Printf("[State Update] Broadcasting state '%s' to all clients (session: %s)", state, h.sessionID) stateEvent := StateUpdateEvent{ Type: EventStateUpdate, SessionID: h.sessionID, diff --git a/pkg/client/client.go b/pkg/client/client.go index 6519c92..1f46e4d 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -98,6 +98,7 @@ func (c *Client) writePump() { log.Printf("Failed to close websocket: %v", err) return } + log.Println("Closing send channel, transitioning to executing state") c.setState(ws.StateExecuting) } @@ -124,8 +125,9 @@ func (c *Client) handleMessage(msg ws.Message) { log.Printf("Error parsing stateUpdate: %v", err) return } + oldState := c.State() c.setState(update.NewState) - log.Printf("State updated: %s", update.NewState) + log.Printf("[State Change] %s -> %s", oldState, update.NewState) case ws.EventBreakpointHit: var hit ws.BreakpointHitEvent @@ -133,7 +135,9 @@ func (c *Client) handleMessage(msg ws.Message) { log.Printf("Error parsing breakpointHit: %v", err) return } + oldState := c.State() c.setState(ws.StateBreakpoint) + log.Printf("[State Change] %s -> breakpoint", oldState) log.Printf("Breakpoint hit at %s:%d in %s", hit.Filename, hit.Line, hit.Function) case ws.EventInitialBreakpoint: @@ -142,7 +146,9 @@ func (c *Client) handleMessage(msg ws.Message) { log.Printf("Error parsing initialBreakpoint: %v", err) return } + oldState := c.State() c.setState(ws.StateBreakpoint) + log.Printf("[State Change] %s -> breakpoint", oldState) log.Printf("Initial breakpoint hit (pid=%d)", initial.PID) default: @@ -164,8 +170,9 @@ func unmarshalJSON(data []byte, v any) error { func (c *Client) SendCommand(cmdType string, payload []byte) error { // TODO: decide which states allow which commands - if c.State() != ws.StateBreakpoint { - return fmt.Errorf("cannot send command in state: %s", c.State()) + currentState := c.State() + if currentState != ws.StateBreakpoint { + return fmt.Errorf("cannot send command '%s' in state '%s' (must be in 'breakpoint' state)", cmdType, currentState) } msg := ws.Message{ Type: cmdType, @@ -174,7 +181,7 @@ func (c *Client) SendCommand(cmdType string, payload []byte) error { select { case c.send <- msg: - log.Printf("Queued command: %s", cmdType) + log.Printf("[Command] Sent %s command (state: %s)", cmdType, c.State()) return nil case <-c.done: return fmt.Errorf("connection closed") From 06d2bf843fb9d3992be45d0e89668dd451e861ad Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Tue, 17 Feb 2026 23:42:56 +0000 Subject: [PATCH 24/36] chore: improve dummy client for debugging --- cmd/bingo-client/main.go | 6 ++++++ internal/ws/protocol.go | 1 + pkg/client/client.go | 10 ++++++---- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/cmd/bingo-client/main.go b/cmd/bingo-client/main.go index 0622902..33dc145 100644 --- a/cmd/bingo-client/main.go +++ b/cmd/bingo-client/main.go @@ -8,6 +8,7 @@ import ( "os" "strconv" "strings" + "time" "github.com/bingosuite/bingo/config" "github.com/bingosuite/bingo/pkg/client" @@ -43,6 +44,7 @@ func main() { log.Println("Connected. Commands: start , c=continue, s=step, b= , state, q=quit") scanner := bufio.NewScanner(os.Stdin) for { + time.Sleep(100 * time.Millisecond) fmt.Printf("[%s] > ", c.State()) if !scanner.Scan() { break @@ -54,8 +56,10 @@ func main() { switch input { case "c", "continue": cmdErr = c.Continue() + time.Sleep(100 * time.Millisecond) case "s", "step", "stepover": cmdErr = c.StepOver() + time.Sleep(100 * time.Millisecond) case "state": fmt.Printf("state=%s session=%s\n", c.State(), c.SessionID()) case "q", "quit", "exit": @@ -71,6 +75,8 @@ func main() { cmdErr = nil break } + // Give async state updates time to arrive before showing next prompt + time.Sleep(100 * time.Millisecond) break } cmdErr = handleBreakpointCommand(c, raw) diff --git a/internal/ws/protocol.go b/internal/ws/protocol.go index 401e0f1..02ca458 100644 --- a/internal/ws/protocol.go +++ b/internal/ws/protocol.go @@ -10,6 +10,7 @@ type Message struct { type State string const ( + StateReady State = "ready" StateExecuting State = "executing" StateBreakpoint State = "breakpoint" ) diff --git a/pkg/client/client.go b/pkg/client/client.go index 1f46e4d..670246c 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -28,7 +28,7 @@ func NewClient(serverURL, sessionID string) *Client { sessionID: sessionID, send: make(chan ws.Message, 256), done: make(chan struct{}), - state: ws.StateExecuting, + state: ws.StateReady, } return c } @@ -126,8 +126,10 @@ func (c *Client) handleMessage(msg ws.Message) { return } oldState := c.State() - c.setState(update.NewState) - log.Printf("[State Change] %s -> %s", oldState, update.NewState) + if oldState != update.NewState { + c.setState(update.NewState) + log.Printf("[State Change] %s -> %s", oldState, update.NewState) + } case ws.EventBreakpointHit: var hit ws.BreakpointHitEvent @@ -229,7 +231,7 @@ func (c *Client) StartDebug(targetPath string) error { select { case c.send <- msg: - log.Printf("Queued command: %s", msg.Type) + log.Printf("[Command] Sent %s command (state: %s)", msg.Type, c.State()) return nil case <-c.done: return fmt.Errorf("connection closed") From 479c08b8a07eb81ce7f1406e95e4b4cdcf25c62a Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Wed, 18 Feb 2026 00:15:50 +0000 Subject: [PATCH 25/36] feat: add proper line reader to cli --- cmd/bingo-client/main.go | 57 +++++++++++++++++++++++++++++++++++----- go.mod | 3 ++- go.sum | 4 +++ 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/cmd/bingo-client/main.go b/cmd/bingo-client/main.go index 33dc145..5853a84 100644 --- a/cmd/bingo-client/main.go +++ b/cmd/bingo-client/main.go @@ -4,6 +4,7 @@ import ( "bufio" "flag" "fmt" + "io" "log" "os" "strconv" @@ -12,6 +13,8 @@ import ( "github.com/bingosuite/bingo/config" "github.com/bingosuite/bingo/pkg/client" + "github.com/peterh/liner" + "golang.org/x/term" ) func main() { @@ -42,14 +45,57 @@ func main() { } log.Println("Connected. Commands: start , c=continue, s=step, b= , state, q=quit") - scanner := bufio.NewScanner(os.Stdin) + + inputReader := bufio.NewReader(os.Stdin) + useRawInput := term.IsTerminal(int(os.Stdin.Fd())) + var lineEditor *liner.State + if useRawInput { + lineEditor = liner.NewLiner() + lineEditor.SetCtrlCAborts(true) + lineEditor.SetTabCompletionStyle(liner.TabPrints) + defer func() { + _ = lineEditor.Close() + }() + } + + history := make([]string, 0, 64) + for { time.Sleep(100 * time.Millisecond) - fmt.Printf("[%s] > ", c.State()) - if !scanner.Scan() { + prompt := fmt.Sprintf("[%s] > ", c.State()) + var rawLine string + var readErr error + if useRawInput { + rawLine, readErr = lineEditor.Prompt(prompt) + } else { + fmt.Print(prompt) + line, err := inputReader.ReadString('\n') + if err != nil { + if err != io.EOF { + log.Printf("Stdin error: %v", err) + } + break + } + rawLine = strings.TrimRight(line, "\r\n") + } + if readErr != nil { + if readErr == liner.ErrPromptAborted || readErr == io.EOF { + break + } + log.Printf("Stdin error: %v", readErr) break } - raw := strings.TrimSpace(scanner.Text()) + + raw := strings.TrimSpace(rawLine) + if raw != "" { + if len(history) == 0 || history[len(history)-1] != rawLine { + history = append(history, rawLine) + if lineEditor != nil { + lineEditor.AppendHistory(rawLine) + } + } + } + input := strings.ToLower(raw) fields := strings.Fields(raw) var cmdErr error @@ -90,9 +136,6 @@ func main() { } } - if err := scanner.Err(); err != nil { - log.Printf("Stdin error: %v", err) - } _ = c.Close() } diff --git a/go.mod b/go.mod index 57686a8..470f467 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/onsi/ginkgo/v2 v2.27.5 github.com/onsi/gomega v1.39.0 + github.com/peterh/liner v1.2.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -68,7 +69,7 @@ require ( golang.org/x/net v0.43.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.35.0 // indirect - golang.org/x/term v0.34.0 // indirect + golang.org/x/term v0.34.0 golang.org/x/text v0.29.0 // indirect golang.org/x/tools v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 05a66fc..3ca58b5 100644 --- a/go.sum +++ b/go.sum @@ -94,6 +94,7 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q= @@ -116,6 +117,8 @@ github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= +github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -154,6 +157,7 @@ golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= From 914a7fd67f2563d612bcb344ed859fa6a86891f0 Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Wed, 18 Feb 2026 00:49:27 +0000 Subject: [PATCH 26/36] feat: cli config --- cmd/bingo-client/main.go | 18 ++++--- config/config.go | 8 +++ config/config.yml | 3 ++ internal/cli/cli.go | 103 --------------------------------------- internal/cli/cli_test.go | 1 - justfile | 19 ++++++-- 6 files changed, 38 insertions(+), 114 deletions(-) delete mode 100644 internal/cli/cli.go delete mode 100644 internal/cli/cli_test.go diff --git a/cmd/bingo-client/main.go b/cmd/bingo-client/main.go index 5853a84..0b4a75e 100644 --- a/cmd/bingo-client/main.go +++ b/cmd/bingo-client/main.go @@ -23,12 +23,18 @@ func main() { log.Printf("Failed to load config: %v", err) } - defaultAddr := "localhost:8080" - if cfg != nil && cfg.Server.Addr != "" { - if strings.HasPrefix(cfg.Server.Addr, ":") { - defaultAddr = "localhost" + cfg.Server.Addr - } else { - defaultAddr = cfg.Server.Addr + if cfg == nil { + cfg = config.Default() + } + + defaultAddr := cfg.CLI.Host + if cfg.CLI.Host == "" { + if cfg.Server.Addr != "" { + if strings.HasPrefix(cfg.Server.Addr, ":") { + defaultAddr = "localhost" + cfg.Server.Addr + } else { + defaultAddr = cfg.Server.Addr + } } } diff --git a/config/config.go b/config/config.go index 88739de..93123fe 100644 --- a/config/config.go +++ b/config/config.go @@ -11,6 +11,7 @@ import ( type Config struct { WebSocket WebSocketConfig `yaml:"websocket"` Server ServerConfig `yaml:"server"` + CLI CLIConfig `yaml:"cli"` Logging LoggingConfig `yaml:"logging"` } @@ -23,6 +24,10 @@ type ServerConfig struct { Addr string `yaml:"addr"` } +type CLIConfig struct { + Host string `yaml:"host"` +} + type LoggingConfig struct { Level string `yaml:"level"` } @@ -36,6 +41,9 @@ func Default() *Config { Server: ServerConfig{ Addr: ":8080", }, + CLI: CLIConfig{ + Host: "localhost:8080", + }, Logging: LoggingConfig{ Level: "info", }, diff --git a/config/config.yml b/config/config.yml index 1891d74..09be365 100644 --- a/config/config.yml +++ b/config/config.yml @@ -4,6 +4,9 @@ websocket: server: addr: ":8080" + +cli: + host: "localhost:8080" logging: level: info diff --git a/internal/cli/cli.go b/internal/cli/cli.go deleted file mode 100644 index 825263e..0000000 --- a/internal/cli/cli.go +++ /dev/null @@ -1,103 +0,0 @@ -package cli - -import ( - "bufio" - "fmt" - "os" - "strconv" - "strings" -) - -func Resume( - pid int, - targetFile string, - currentLine int, - breakpointSet bool, - originalCode []byte, - setBreak func(int, string, int) (bool, []byte), -) (bool, bool, []byte, int) { - sub := false - scanner := bufio.NewScanner(os.Stdin) - fmt.Printf("\n(C)ontinue, (S)tep, set (B)reakpoint or (Q)uit >") - for { - scanner.Scan() - input := scanner.Text() - switch strings.ToUpper(input) { - case "C": - return true, breakpointSet, originalCode, currentLine - case "S": - return false, breakpointSet, originalCode, currentLine - case "B": - fmt.Printf("\nEnter line number in %s: >", targetFile) - sub = true - case "Q": - os.Exit(0) - default: - if sub { - line, _ := strconv.Atoi(input) - breakpointSet, originalCode = setBreak(pid, targetFile, line) - return true, breakpointSet, originalCode, line - } - fmt.Printf("Unexpected input %s\n", input) - fmt.Printf("\n(C)ontinue, (S)tep, set (B)reakpoint or (Q)uit? > ") - } - } -} - -/*func outputStack(symTable *gosym.Table, pid int, ip uint64, sp uint64, bp uint64) { - - // ip = Instruction Pointer - // sp = Stack Pointer - // bp = Base(Frame) Pointer - - _, _, fn = symTable.PCToLine(ip) - var i uint64 - var nextbp uint64 - - for { - - // Only works if stack frame is [Return Address] - // [locals] - // [Saved RBP] - i = 0 - frameSize := bp - sp + 8 - - //Can happen when we look at bp and sp while they're being updated - if frameSize > 1000 || bp == 0 { - fmt.Printf("Weird frame size: SP: %X | BP: %X \n", sp, bp) - frameSize = 32 - bp = sp + frameSize - 8 - } - - // Read stack memory at sp into b - b := make([]byte, frameSize) - _, err := syscall.PtracePeekData(pid, uintptr(sp), b) - if err != nil { - panic(err) - } - - // Reads return address into content - content := binary.LittleEndian.Uint64((b[i : i+8])) - _, lineno, nextfn := symTable.PCToLine(content) - if nextfn != nil { - fn = nextfn - fmt.Printf(" called by %s line %d\n", fn.Name, lineno) - } - - //Rest of the frame - for i = 8; sp+1 <= bp; i += 8 { - content := binary.LittleEndian.Uint64(b[i : i+8]) - if sp+i == bp { - nextbp = content - } - } - - //Stop stack trace at main.main. If bp and sp are being updated we could miss main.main so we backstop with runtime.amin - if fn.Name == "main.main" || fn.Name == "runtime.main" { - break - } - - sp = sp + i - bp = nextbp - } -}*/ diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go deleted file mode 100644 index 7f1e458..0000000 --- a/internal/cli/cli_test.go +++ /dev/null @@ -1 +0,0 @@ -package cli diff --git a/justfile b/justfile index c954e8a..7481454 100644 --- a/justfile +++ b/justfile @@ -9,6 +9,20 @@ run: # Build the Target, build BinGo and run the Target go: build-target build run +# Run test cli with optional server and session +# Usage: just cli (both default) +# just cli localhost:8080 (custom server) +# just cli - abc123 (default server, custom session) +# just cli localhost:8080 abc123 (both custom) +# Use "-" for default value +cli server="" session="": + #!/usr/bin/env bash + set -euo pipefail + ARGS="" + if [ -n "{{server}}" ] && [ "{{server}}" != "-" ]; then ARGS="$ARGS --server {{server}}"; fi + if [ -n "{{session}}" ] && [ "{{session}}" != "-" ]; then ARGS="$ARGS --session {{session}}"; fi + go run ./cmd/bingo-client/main.go $ARGS + # Build the Target with maximum debugging information build-target: go build --gcflags="all=-N -l" -o ./build/target/target ./cmd/target @@ -31,7 +45,4 @@ coverage PKG="./...": # Run integration tests integration: - go run github.com/onsi/ginkgo/v2/ginkgo -r ./test/integration/. - -client: - go run ./cmd/client/client.go -server localhost:8080 -session test-session \ No newline at end of file + go run github.com/onsi/ginkgo/v2/ginkgo -r ./test/integration/. \ No newline at end of file From 8744cd2da56dc162f53e7f5766af9e35a4e5344e Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Wed, 18 Feb 2026 01:19:41 +0000 Subject: [PATCH 27/36] feat: graceful exit --- cmd/bingo-client/main.go | 11 ++++- internal/debugger/debugger.go | 59 ++++++++++++++++++++++--- internal/ws/connection.go | 69 +++++++++++++++++++++-------- internal/ws/hub.go | 57 ++++++++++++++++++++++-- internal/ws/server.go | 7 +-- pkg/client/client.go | 83 ++++++++++++++++++++++++++++++----- 6 files changed, 243 insertions(+), 43 deletions(-) diff --git a/cmd/bingo-client/main.go b/cmd/bingo-client/main.go index 0b4a75e..ca13f34 100644 --- a/cmd/bingo-client/main.go +++ b/cmd/bingo-client/main.go @@ -49,8 +49,14 @@ func main() { if err := c.Run(); err != nil { log.Fatalf("Failed to start client: %v", err) } + go func() { + if err := c.Wait(); err != nil { + log.Println("Server disconnected") + os.Exit(1) + } + }() - log.Println("Connected. Commands: start , c=continue, s=step, b= , state, q=quit") + log.Println("Connected. Commands: start , stop, c=continue, s=step, b= , state, q=quit") inputReader := bufio.NewReader(os.Stdin) useRawInput := term.IsTerminal(int(os.Stdin.Fd())) @@ -114,6 +120,9 @@ func main() { time.Sleep(100 * time.Millisecond) case "state": fmt.Printf("state=%s session=%s\n", c.State(), c.SessionID()) + case "stop": + cmdErr = c.Stop() + time.Sleep(100 * time.Millisecond) case "q", "quit", "exit": _ = c.Close() return diff --git a/internal/debugger/debugger.go b/internal/debugger/debugger.go index 08330f9..34245f6 100644 --- a/internal/debugger/debugger.go +++ b/internal/debugger/debugger.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "syscall" + "time" "github.com/bingosuite/bingo/internal/debuginfo" ) @@ -96,12 +97,20 @@ func (d *Debugger) StartWithDebug(path string) { // Set initial breakpoints while the process is stopped at the initial SIGTRAP d.initialBreakpointHit() + // Check if we were stopped during initial breakpoint + select { + case <-d.EndDebugSession: + log.Println("Debug session ended during initial breakpoint, cleaning up") + return + default: + // Continue to debug loop + } + log.Println("STARTING DEBUG LOOP") d.debug() - // Wait until debug session exits - d.EndDebugSession <- true + log.Println("Debug loop ended, signaling completion") } @@ -174,7 +183,19 @@ func (d *Debugger) SingleStep(pid int) { } func (d *Debugger) StopDebug() { - d.EndDebugSession <- true + // Detach from the target process, letting it continue running + if d.DebugInfo.Target.PID > 0 { + log.Printf("Detaching from target process (PID: %d)", d.DebugInfo.Target.PID) + if err := syscall.PtraceDetach(d.DebugInfo.Target.PID); err != nil { + log.Printf("Failed to detach from target process: %v (might have already exited)", err) + } + } + // Signal the debug loop to exit + select { + case d.EndDebugSession <- true: + default: + // Channel might be full, that's ok + } } func (d *Debugger) SetBreakpoint(line int) error { @@ -211,12 +232,29 @@ func (d *Debugger) ClearBreakpoint(line int) error { func (d *Debugger) debug() { for { + // Check if we should stop debugging + select { + case <-d.EndDebugSession: + log.Println("Debug session ending, exiting debug loop") + return + default: + // Continue with wait + } + // Wait until any of the child processes of the target is interrupted or ends var waitStatus syscall.WaitStatus - wpid, err := syscall.Wait4(-1*d.DebugInfo.Target.PGID, &waitStatus, 0, nil) // TODO: handle concurrency + wpid, err := syscall.Wait4(-1*d.DebugInfo.Target.PGID, &waitStatus, syscall.WNOHANG, nil) if err != nil { log.Printf("Failed to wait for the target or any of its threads: %v", err) - panic(err) + // Don't panic, just exit gracefully + return + } + + // No process state changed yet + if wpid == 0 { + // Sleep briefly to avoid busy waiting + time.Sleep(10 * time.Millisecond) + continue } if waitStatus.Exited() { @@ -233,10 +271,19 @@ func (d *Debugger) debug() { d.breakpointHit(wpid) + // Check if we were signaled to stop during breakpoint handling + select { + case <-d.EndDebugSession: + log.Println("Debug session ending after breakpoint handling") + return + default: + } + } else { if err := syscall.PtraceCont(wpid, 0); err != nil { log.Printf("Failed to resume target execution: %v", err) - panic(err) + // Don't panic, might have been detached + return } } } diff --git a/internal/ws/connection.go b/internal/ws/connection.go index e3bb70f..1d0a63e 100644 --- a/internal/ws/connection.go +++ b/internal/ws/connection.go @@ -2,15 +2,19 @@ package ws import ( "log" + "strings" + "sync" "github.com/gorilla/websocket" ) type Connection struct { - id string - conn *websocket.Conn - hub *Hub - send chan Message + id string + conn *websocket.Conn + hub *Hub + send chan Message + closeOnce sync.Once + sendOnce sync.Once } func NewConnection(conn *websocket.Conn, hub *Hub, id string) *Connection { @@ -25,19 +29,17 @@ func NewConnection(conn *websocket.Conn, hub *Hub, id string) *Connection { func (c *Connection) ReadPump() { defer func() { c.hub.Unregister(c) - if err := c.conn.Close(); err != nil { - log.Printf("Connection %s close error: %v", c.id, err) - } + c.closeConn() }() for { var msg Message err := c.conn.ReadJSON(&msg) - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { - log.Printf("Connection %s unexpected close: %v", c.id, err) - break - } if err != nil { + // Only log truly unexpected errors, not normal client disconnects + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure, websocket.CloseAbnormalClosure) { + log.Printf("Connection %s unexpected close: %v", c.id, err) + } break } c.hub.SendCommand(msg) @@ -45,11 +47,7 @@ func (c *Connection) ReadPump() { } func (c *Connection) WritePump() { - defer func() { - if err := c.conn.Close(); err != nil { - log.Printf("Connection %s close error: %v", c.id, err) - } - }() + defer c.closeConn() for message := range c.send { if err := c.conn.WriteJSON(message); err != nil { @@ -57,8 +55,43 @@ func (c *Connection) WritePump() { return } } + // Send channel closed by server - try to send graceful close message + // If the connection was already closed by client, this will fail silently if err := c.conn.WriteMessage(websocket.CloseMessage, []byte{}); err != nil { - log.Printf("Failed to close websocket: %v", err) - return + // Don't log if connection was already closed + if !isConnectionClosedError(err) { + log.Printf("Connection %s: failed to send close message: %v", c.id, err) + } + } +} + +func (c *Connection) closeConn() { + c.closeOnce.Do(func() { + if err := c.conn.Close(); err != nil { + // Don't log if connection was already closed + if !isConnectionClosedError(err) { + log.Printf("Connection %s close error: %v", c.id, err) + } + } + }) +} + +func (c *Connection) Close() { + c.closeConn() +} + +func (c *Connection) CloseSend() { + c.sendOnce.Do(func() { + close(c.send) + }) +} + +func isConnectionClosedError(err error) bool { + if err == nil { + return false } + errMsg := err.Error() + return websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) || + strings.Contains(errMsg, "use of closed network connection") || + strings.Contains(errMsg, "broken pipe") } diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 7a8c48a..00c57be 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -55,10 +55,13 @@ func (h *Hub) Run() { ticker := time.NewTicker(hubTickerInterval) defer ticker.Stop() - // Start listening for debugger events if debugger is attached + // Start listening for debugger events if debugger is already attached at startup + // (for compatibility with existing tests/code that pass a debugger at creation) + h.mu.RLock() if h.debugger != nil { go h.listenForDebuggerEvents() } + h.mu.RUnlock() for { select { @@ -83,7 +86,7 @@ func (h *Hub) Run() { h.mu.Lock() if _, ok := h.connections[client]; ok { delete(h.connections, client) - close(client.send) + client.CloseSend() log.Printf("Client %s disconnected from hub %s (%d remaining)", client.id, h.sessionID, len(h.connections)) // When last client leaves, shutdown hub @@ -243,7 +246,8 @@ func (h *Hub) listenForDebuggerEvents() { h.Broadcast(stateMessage) case <-h.debugger.EndDebugSession: - log.Println("Debugger event listener ending") + log.Println("Debugger event listener ending, sending state update to ready") + h.sendStateUpdate(StateReady) return } } @@ -264,6 +268,16 @@ func (h *Hub) handleCommand(cmd Message) { return } log.Printf("[Command] StartDebug received: %s (session: %s)", startDebugCmd.TargetPath, h.sessionID) + + // Create a new debugger instance for this debug session + h.mu.Lock() + h.debugger = debugger.NewDebugger() + h.mu.Unlock() + + // Start listening for events from this new debugger + go h.listenForDebuggerEvents() + + // Start the debug session go h.debugger.StartWithDebug(startDebugCmd.TargetPath) case CmdContinue: @@ -273,6 +287,15 @@ func (h *Hub) handleCommand(cmd Message) { return } log.Printf("[Command] Continue received (session: %s)", h.sessionID) + + h.mu.RLock() + if h.debugger == nil { + h.mu.RUnlock() + log.Printf("[Error] No active debug session to continue (session: %s)", h.sessionID) + return + } + h.mu.RUnlock() + log.Printf("[State Change] Transitioning to executing state (session: %s)", h.sessionID) // Send executing state update @@ -296,6 +319,15 @@ func (h *Hub) handleCommand(cmd Message) { return } log.Printf("[Command] StepOver received (session: %s)", h.sessionID) + + h.mu.RLock() + if h.debugger == nil { + h.mu.RUnlock() + log.Printf("[Error] No active debug session to step (session: %s)", h.sessionID) + return + } + h.mu.RUnlock() + log.Printf("[State Change] Transitioning to executing state (session: %s)", h.sessionID) // Send executing state update @@ -320,6 +352,14 @@ func (h *Hub) handleCommand(cmd Message) { } log.Printf("[Command] SetBreakpoint received: %s:%d (session: %s)", setBreakpointCmd.Filename, setBreakpointCmd.Line, h.sessionID) + h.mu.RLock() + if h.debugger == nil { + h.mu.RUnlock() + log.Printf("[Error] No active debug session to set breakpoint (session: %s)", h.sessionID) + return + } + h.mu.RUnlock() + // Send command to debugger debugCmd := debugger.DebugCommand{ Type: "setBreakpoint", @@ -343,6 +383,16 @@ func (h *Hub) handleCommand(cmd Message) { } log.Printf("[Command] Exit received (session: %s)", h.sessionID) + h.mu.RLock() + if h.debugger == nil { + h.mu.RUnlock() + log.Printf("[Info] No active debug session to stop (session: %s)", h.sessionID) + // Still send state update to ready in case client is confused + h.sendStateUpdate(StateReady) + return + } + h.mu.RUnlock() + // Send command to debugger debugCmd := debugger.DebugCommand{ Type: "quit", @@ -353,6 +403,7 @@ func (h *Hub) handleCommand(cmd Message) { default: log.Printf("[Error] Failed to send quit command to debugger - channel full") } + // Note: State update to 'ready' will be sent by listenForDebuggerEvents when EndDebugSession is received default: log.Printf("[Error] Unknown command type: %s", cmd.Type) diff --git a/internal/ws/server.go b/internal/ws/server.go index 81d6a57..ed669d5 100644 --- a/internal/ws/server.go +++ b/internal/ws/server.go @@ -152,11 +152,8 @@ func (s *Server) Shutdown() { // Close all connections in the hub for c := range hub.connections { - close(c.send) - if err := c.conn.Close(); err != nil { - log.Printf("Error closing connection %s: %v", c.id, err) - panic(err) - } + c.CloseSend() + c.Close() } delete(s.hubs, sessionID) diff --git a/pkg/client/client.go b/pkg/client/client.go index 670246c..4771a1c 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -2,9 +2,11 @@ package client import ( "encoding/json" + "errors" "fmt" "log" "net/url" + "strings" "sync" "github.com/bingosuite/bingo/internal/ws" @@ -20,8 +22,11 @@ type Client struct { sessionMu sync.RWMutex stateMu sync.RWMutex state ws.State + closeOnce sync.Once } +var ErrDisconnected = errors.New("server disconnected") + func NewClient(serverURL, sessionID string) *Client { c := &Client{ serverURL: serverURL, @@ -69,9 +74,7 @@ func (c *Client) Run() error { func (c *Client) readPump() { defer func() { close(c.done) - if err := c.conn.Close(); err != nil { - log.Printf("Close error: %v", err) - } + c.closeConn() }() for { @@ -88,6 +91,8 @@ func (c *Client) readPump() { } func (c *Client) writePump() { + defer c.closeConn() + for message := range c.send { if err := c.conn.WriteJSON(message); err != nil { log.Printf("Write error: %v", err) @@ -95,11 +100,11 @@ func (c *Client) writePump() { } } if err := c.conn.WriteMessage(websocket.CloseMessage, []byte{}); err != nil { - log.Printf("Failed to close websocket: %v", err) - return + if !isConnectionClosedError(err) { + log.Printf("Failed to send close message: %v", err) + } } - log.Println("Closing send channel, transitioning to executing state") - c.setState(ws.StateExecuting) + log.Println("Write pump closing gracefully") } func (c *Client) handleMessage(msg ws.Message) { @@ -238,6 +243,30 @@ func (c *Client) StartDebug(targetPath string) error { } } +func (c *Client) Stop() error { + cmd := ws.ExitCmd{ + Type: ws.CmdExit, + SessionID: c.SessionID(), + } + payload, err := marshalJSON(cmd) + if err != nil { + return err + } + msg := ws.Message{ + Type: string(ws.CmdExit), + Data: payload, + } + + select { + case c.send <- msg: + log.Printf("[Command] Sent %s command (state: %s)", msg.Type, c.State()) + // State will be updated to 'ready' when server confirms debug session has stopped + return nil + case <-c.done: + return fmt.Errorf("connection closed") + } +} + func (c *Client) SetBreakpoint(filename string, line int) error { cmd := ws.SetBreakpointCmd{ Type: ws.CmdSetBreakpoint, @@ -280,9 +309,43 @@ func (c *Client) State() ws.State { return c.state } +func (c *Client) Done() <-chan struct{} { + return c.done +} + +func (c *Client) Wait() error { + <-c.done + return ErrDisconnected +} + func (c *Client) Close() error { - if c.conn != nil { - return c.conn.Close() + var err error + c.closeOnce.Do(func() { + if c.conn != nil { + err = c.conn.Close() + } + }) + return err +} + +func (c *Client) closeConn() { + c.closeOnce.Do(func() { + if c.conn != nil { + if err := c.conn.Close(); err != nil { + if !isConnectionClosedError(err) { + log.Printf("Close error: %v", err) + } + } + } + }) +} + +func isConnectionClosedError(err error) bool { + if err == nil { + return false } - return nil + errMsg := err.Error() + return websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) || + strings.Contains(errMsg, "use of closed network connection") || + strings.Contains(errMsg, "broken pipe") } From 52f85472dea3c8e009cae7b8f6ed02272e5115fb Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Sun, 22 Feb 2026 18:53:46 +0000 Subject: [PATCH 28/36] fix: don't hang client on server shutdown --- cmd/bingo-client/main.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/cmd/bingo-client/main.go b/cmd/bingo-client/main.go index ca13f34..9d59375 100644 --- a/cmd/bingo-client/main.go +++ b/cmd/bingo-client/main.go @@ -49,12 +49,6 @@ func main() { if err := c.Run(); err != nil { log.Fatalf("Failed to start client: %v", err) } - go func() { - if err := c.Wait(); err != nil { - log.Println("Server disconnected") - os.Exit(1) - } - }() log.Println("Connected. Commands: start , stop, c=continue, s=step, b= , state, q=quit") @@ -70,6 +64,16 @@ func main() { }() } + go func() { + if err := c.Wait(); err != nil { + log.Println("Server disconnected") + if lineEditor != nil { + _ = lineEditor.Close() + } + os.Exit(1) + } + }() + history := make([]string, 0, 64) for { From f3e45e90954e371948644d6eb1f28b2ed5180c0b Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Sun, 22 Feb 2026 18:57:26 +0000 Subject: [PATCH 29/36] fix: insane clutch --- cmd/target/target.go | 2 +- internal/debugger/debugger.go | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/cmd/target/target.go b/cmd/target/target.go index c9fd013..dedf07b 100644 --- a/cmd/target/target.go +++ b/cmd/target/target.go @@ -7,7 +7,7 @@ import ( func main() { var count int - for { + for range 3 { fmt.Println("Hello, World") count = count + 1 count = count * 1 diff --git a/internal/debugger/debugger.go b/internal/debugger/debugger.go index 34245f6..4abd5be 100644 --- a/internal/debugger/debugger.go +++ b/internal/debugger/debugger.go @@ -5,6 +5,7 @@ import ( "log" "os" "os/exec" + "runtime" "syscall" "time" @@ -60,6 +61,13 @@ func NewDebugger() *Debugger { } func (d *Debugger) StartWithDebug(path string) { + // Lock this goroutine to the current OS thread. + // Linux ptrace requires that all ptrace calls for a given traced process + // originate from the same OS thread that performed the initial attach. + // Without this, the Go scheduler may migrate the goroutine to a different + // OS thread, causing ptrace calls to fail with ESRCH ("no such process"). + runtime.LockOSThread() + defer runtime.UnlockOSThread() // Set up target for execution cmd := exec.Command(path) @@ -208,7 +216,7 @@ func (d *Debugger) SetBreakpoint(line int) error { original := make([]byte, len(bpCode)) if _, err := syscall.PtracePeekData(d.DebugInfo.Target.PID, uintptr(pc), original); err != nil { - return fmt.Errorf("failed to read original machine code into memory: %v", err) + return fmt.Errorf("failed to read original machine code into memory: %v for PID: %d", err, d.DebugInfo.Target.PID) } if _, err := syscall.PtracePokeData(d.DebugInfo.Target.PID, uintptr(pc), bpCode); err != nil { return fmt.Errorf("failed to write breakpoint into memory: %v", err) @@ -281,7 +289,7 @@ func (d *Debugger) debug() { } else { if err := syscall.PtraceCont(wpid, 0); err != nil { - log.Printf("Failed to resume target execution: %v", err) + log.Printf("Failed to resume target execution: %v for PID: %d", err, wpid) // Don't panic, might have been detached return } @@ -305,6 +313,7 @@ func (d *Debugger) initialBreakpointHit() { select { case cmd := <-d.DebugCommand: log.Printf("Initial state - received command: %s", cmd.Type) + switch cmd.Type { case "setBreakpoint": if data, ok := cmd.Data.(map[string]interface{}); ok { From 87f5ff7c72e4b744f5eeb520a83dd45daa445b21 Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Sun, 22 Feb 2026 19:17:18 +0000 Subject: [PATCH 30/36] fix: send state update on end of debug session --- internal/debugger/debugger.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/debugger/debugger.go b/internal/debugger/debugger.go index 4abd5be..0c59ebc 100644 --- a/internal/debugger/debugger.go +++ b/internal/debugger/debugger.go @@ -233,7 +233,7 @@ func (d *Debugger) ClearBreakpoint(line int) error { panic(err) } if _, err := syscall.PtracePokeData(d.DebugInfo.Target.PID, uintptr(pc), d.Breakpoints[pc]); err != nil { - return fmt.Errorf("failed to write breakpoint into memory: %v", err) + return fmt.Errorf("failed to write breakpoint ndinto memory: %v", err) } return nil } @@ -268,7 +268,13 @@ func (d *Debugger) debug() { if waitStatus.Exited() { if wpid == d.DebugInfo.Target.PID { // If target exited, terminate log.Printf("Target %v execution completed", d.DebugInfo.Target.Path) - break + // Signal the end of debug session to hub + select { + case d.EndDebugSession <- true: + default: + // Channel might be full or already closed, that's ok + } + return } else { log.Printf("Thread exited with PID: %v", wpid) } From 35b13bda2809cbdab10f61f9b7ca6b2e974c83c9 Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Sun, 22 Feb 2026 19:25:02 +0000 Subject: [PATCH 31/36] fix: remove faulty state updates on client --- cmd/bingo-client/main.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/bingo-client/main.go b/cmd/bingo-client/main.go index 9d59375..675617d 100644 --- a/cmd/bingo-client/main.go +++ b/cmd/bingo-client/main.go @@ -77,8 +77,7 @@ func main() { history := make([]string, 0, 64) for { - time.Sleep(100 * time.Millisecond) - prompt := fmt.Sprintf("[%s] > ", c.State()) + prompt := "" var rawLine string var readErr error if useRawInput { From 4d1b531dbbd954c2aeb86344d330be4355b7a5a9 Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Sun, 22 Feb 2026 21:41:01 +0000 Subject: [PATCH 32/36] fix: sanitize input path --- internal/debugger/debugger.go | 47 +++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/internal/debugger/debugger.go b/internal/debugger/debugger.go index 0c59ebc..2f7d21b 100644 --- a/internal/debugger/debugger.go +++ b/internal/debugger/debugger.go @@ -5,7 +5,9 @@ import ( "log" "os" "os/exec" + "path/filepath" "runtime" + "strings" "syscall" "time" @@ -60,6 +62,40 @@ func NewDebugger() *Debugger { } } +// validateTargetPath resolves path to an absolute path, ensures it stays +// within the current working directory, and confirms it is a regular +// executable file. This prevents command injection from user-supplied input. +func validateTargetPath(path string) (string, error) { + // Resolve to absolute path to eliminate any relative traversal tricks + abs, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + return "", fmt.Errorf("invalid target path: %w", err) + } + + // Restrict execution to paths within the working directory + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("could not determine working directory: %w", err) + } + if !strings.HasPrefix(abs, cwd+string(filepath.Separator)) { + return "", fmt.Errorf("target path %q is outside the working directory %q", abs, cwd) + } + + // Confirm it exists and is a regular file (not a directory or device) + info, err := os.Stat(abs) + if err != nil { + return "", fmt.Errorf("target path %q not accessible: %w", abs, err) + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("target path %q is not a regular file", abs) + } + if info.Mode()&0111 == 0 { + return "", fmt.Errorf("target path %q is not executable", abs) + } + + return abs, nil +} + func (d *Debugger) StartWithDebug(path string) { // Lock this goroutine to the current OS thread. // Linux ptrace requires that all ptrace calls for a given traced process @@ -69,8 +105,15 @@ func (d *Debugger) StartWithDebug(path string) { runtime.LockOSThread() defer runtime.UnlockOSThread() + // Validate and sanitise the user-supplied path before passing it to exec. + validatedPath, err := validateTargetPath(path) + if err != nil { + log.Printf("Rejected target path %q: %v", path, err) + return + } + // Set up target for execution - cmd := exec.Command(path) + cmd := exec.Command(validatedPath) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -81,7 +124,7 @@ func (d *Debugger) StartWithDebug(path string) { panic(err) } - dbInf, err := debuginfo.NewDebugInfo(path, cmd.Process.Pid) + dbInf, err := debuginfo.NewDebugInfo(validatedPath, cmd.Process.Pid) if err != nil { log.Printf("Failed to create debug info: %v", err) panic(err) From 240f00ace7451b1e3fac28563d94bc421c42ed33 Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Mon, 23 Feb 2026 18:31:44 +0000 Subject: [PATCH 33/36] fix: remove redundant config info --- cmd/bingo-client/main.go | 12 +++--------- config/config.go | 8 -------- config/config.yml | 3 --- 3 files changed, 3 insertions(+), 20 deletions(-) diff --git a/cmd/bingo-client/main.go b/cmd/bingo-client/main.go index 675617d..0673ee1 100644 --- a/cmd/bingo-client/main.go +++ b/cmd/bingo-client/main.go @@ -27,15 +27,9 @@ func main() { cfg = config.Default() } - defaultAddr := cfg.CLI.Host - if cfg.CLI.Host == "" { - if cfg.Server.Addr != "" { - if strings.HasPrefix(cfg.Server.Addr, ":") { - defaultAddr = "localhost" + cfg.Server.Addr - } else { - defaultAddr = cfg.Server.Addr - } - } + defaultAddr := cfg.Server.Addr + if strings.HasPrefix(defaultAddr, ":") { + defaultAddr = "localhost" + defaultAddr } server := flag.String("server", defaultAddr, "WebSocket server host:port") diff --git a/config/config.go b/config/config.go index 93123fe..88739de 100644 --- a/config/config.go +++ b/config/config.go @@ -11,7 +11,6 @@ import ( type Config struct { WebSocket WebSocketConfig `yaml:"websocket"` Server ServerConfig `yaml:"server"` - CLI CLIConfig `yaml:"cli"` Logging LoggingConfig `yaml:"logging"` } @@ -24,10 +23,6 @@ type ServerConfig struct { Addr string `yaml:"addr"` } -type CLIConfig struct { - Host string `yaml:"host"` -} - type LoggingConfig struct { Level string `yaml:"level"` } @@ -41,9 +36,6 @@ func Default() *Config { Server: ServerConfig{ Addr: ":8080", }, - CLI: CLIConfig{ - Host: "localhost:8080", - }, Logging: LoggingConfig{ Level: "info", }, diff --git a/config/config.yml b/config/config.yml index 09be365..d11e5af 100644 --- a/config/config.yml +++ b/config/config.yml @@ -5,8 +5,5 @@ websocket: server: addr: ":8080" -cli: - host: "localhost:8080" - logging: level: info From 15538a34f45d96fa28da3d23a94d1d65a31e7221 Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Mon, 23 Feb 2026 19:00:55 +0000 Subject: [PATCH 34/36] fix: initialbreakpointhit protocol --- internal/debugger/debugger.go | 30 +++++++++++------------------- internal/ws/hub.go | 2 +- internal/ws/protocol.go | 2 +- pkg/client/client.go | 2 +- 4 files changed, 14 insertions(+), 22 deletions(-) diff --git a/internal/debugger/debugger.go b/internal/debugger/debugger.go index 2f7d21b..5df637b 100644 --- a/internal/debugger/debugger.go +++ b/internal/debugger/debugger.go @@ -29,7 +29,7 @@ type Debugger struct { // Communication with hub BreakpointHit chan BreakpointEvent - InitialBreakpointHit chan InitialBreakpointEvent + InitialBreakpointHit chan InitialBreakpointHitEvent DebugCommand chan DebugCommand } @@ -41,8 +41,8 @@ type BreakpointEvent struct { Function string `json:"function"` } -// InitialBreakpointEvent represents the initial breakpoint hit when debugging starts -type InitialBreakpointEvent struct { +// InitialBreakpointHitEvent represents the initial breakpoint hit when debugging starts +type InitialBreakpointHitEvent struct { PID int `json:"pid"` } @@ -57,7 +57,7 @@ func NewDebugger() *Debugger { Breakpoints: make(map[uint64][]byte), EndDebugSession: make(chan bool, 1), BreakpointHit: make(chan BreakpointEvent, 1), - InitialBreakpointHit: make(chan InitialBreakpointEvent, 1), + InitialBreakpointHit: make(chan InitialBreakpointHitEvent, 1), DebugCommand: make(chan DebugCommand, 1), } } @@ -349,7 +349,7 @@ func (d *Debugger) debug() { func (d *Debugger) initialBreakpointHit() { // Create initial breakpoint event - event := InitialBreakpointEvent{ + event := InitialBreakpointHitEvent{ PID: d.DebugInfo.Target.PID, } @@ -365,18 +365,14 @@ func (d *Debugger) initialBreakpointHit() { switch cmd.Type { case "setBreakpoint": - if data, ok := cmd.Data.(map[string]interface{}); ok { - if line, ok := data["line"].(int); ok { // JSON numbers are float64 + if data, ok := cmd.Data.(map[string]any); ok { + if line, ok := data["line"].(int); ok { if err := d.SetBreakpoint(int(line)); err != nil { log.Printf("Failed to set breakpoint at line %d: %v", int(line), err) } else { - log.Printf("Breakpoint set at line %d, waiting for next command", int(line)) + log.Printf("Breakpoint set at line %d while at breakpoint", int(line)) } - } else { - log.Printf("Invalid setBreakpoint command: line field missing or wrong type") } - } else { - log.Printf("Invalid setBreakpoint command: data field missing or wrong format") } case "continue": log.Println("Continuing from initial breakpoint") @@ -386,9 +382,7 @@ func (d *Debugger) initialBreakpointHit() { } return // Exit initial breakpoint handling case "step": - log.Println("Stepping from initial breakpoint") - d.SingleStep(d.DebugInfo.Target.PID) - return // Exit initial breakpoint handling + log.Println("Cannot single-step from initial breakpoint") case "quit": d.StopDebug() return @@ -435,8 +429,8 @@ func (d *Debugger) breakpointHit(pid int) { case "step": d.SingleStep(pid) case "setBreakpoint": - if data, ok := cmd.Data.(map[string]interface{}); ok { - if line, ok := data["line"].(float64); ok { // JSON numbers are float64 + if data, ok := cmd.Data.(map[string]any); ok { + if line, ok := data["line"].(int); ok { if err := d.SetBreakpoint(int(line)); err != nil { log.Printf("Failed to set breakpoint at line %d: %v", int(line), err) } else { @@ -444,8 +438,6 @@ func (d *Debugger) breakpointHit(pid int) { } } } - // After setting breakpoint, continue waiting for next command - d.Continue(pid) case "quit": d.StopDebug() return diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 00c57be..dcf9033 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -205,7 +205,7 @@ func (h *Hub) listenForDebuggerEvents() { log.Printf("[Debugger Event] Initial breakpoint hit (PID: %d, session: %s)", initialBpEvent.PID, h.sessionID) // Create and send initial breakpoint event to all clients - event := InitialBreakpointEvent{ + event := InitialBreakpointHitEvent{ Type: EventInitialBreakpoint, SessionID: h.sessionID, PID: initialBpEvent.PID, diff --git a/internal/ws/protocol.go b/internal/ws/protocol.go index 02ca458..b76e1e5 100644 --- a/internal/ws/protocol.go +++ b/internal/ws/protocol.go @@ -46,7 +46,7 @@ type BreakpointHitEvent struct { Function string `json:"function"` } -type InitialBreakpointEvent struct { +type InitialBreakpointHitEvent struct { Type EventType `json:"type"` SessionID string `json:"sessionId"` PID int `json:"pid"` diff --git a/pkg/client/client.go b/pkg/client/client.go index 4771a1c..39cd51a 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -148,7 +148,7 @@ func (c *Client) handleMessage(msg ws.Message) { log.Printf("Breakpoint hit at %s:%d in %s", hit.Filename, hit.Line, hit.Function) case ws.EventInitialBreakpoint: - var initial ws.InitialBreakpointEvent + var initial ws.InitialBreakpointHitEvent if err := unmarshalData(msg.Data, &initial); err != nil { log.Printf("Error parsing initialBreakpoint: %v", err) return From 5e92aad05e2959c431103c701f2ca7131c0ad3d7 Mon Sep 17 00:00:00 2001 From: Sacha Arseneault Date: Mon, 23 Feb 2026 19:05:53 +0000 Subject: [PATCH 35/36] chore: remove incomplete code related to attach --- internal/debugger/debugger.go | 5 ----- internal/ws/hub.go | 8 -------- 2 files changed, 13 deletions(-) diff --git a/internal/debugger/debugger.go b/internal/debugger/debugger.go index 5df637b..2489800 100644 --- a/internal/debugger/debugger.go +++ b/internal/debugger/debugger.go @@ -165,11 +165,6 @@ func (d *Debugger) StartWithDebug(path string) { } -// TODO: figure out how to do -func (d *Debugger) AttachAndDebug(pid int) { - -} - func (d *Debugger) Continue(pid int) { // Read registers var regs syscall.PtraceRegs diff --git a/internal/ws/hub.go b/internal/ws/hub.go index dcf9033..4125e0c 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -55,14 +55,6 @@ func (h *Hub) Run() { ticker := time.NewTicker(hubTickerInterval) defer ticker.Stop() - // Start listening for debugger events if debugger is already attached at startup - // (for compatibility with existing tests/code that pass a debugger at creation) - h.mu.RLock() - if h.debugger != nil { - go h.listenForDebuggerEvents() - } - h.mu.RUnlock() - for { select { case <-ticker.C: From b92b43b9bbd4de99a4779892fc7f4b61fcc8235e Mon Sep 17 00:00:00 2001 From: Xavier Lermusieaux Date: Mon, 23 Feb 2026 23:42:59 +0000 Subject: [PATCH 36/36] feat: works --- internal/debugger/debugger.go | 162 +++++++++++++++---------------- internal/debuginfo/debug_info.go | 1 - internal/ws/connection.go | 8 +- internal/ws/hub.go | 146 +++++++++------------------- internal/ws/server.go | 111 +++++++++++++-------- internal/ws/ws_test.go | 132 ++++++++++++++++++++++--- pkg/client/client.go | 39 +------- 7 files changed, 317 insertions(+), 282 deletions(-) diff --git a/internal/debugger/debugger.go b/internal/debugger/debugger.go index 2489800..16115c4 100644 --- a/internal/debugger/debugger.go +++ b/internal/debugger/debugger.go @@ -48,8 +48,8 @@ type InitialBreakpointHitEvent struct { // DebugCommand represents commands that can be sent to the debugger type DebugCommand struct { - Type string `json:"type"` // "continue", "step", "quit", "setBreakpoint" - Data interface{} `json:"data,omitempty"` + Type string `json:"type"` // "continue", "step", "quit", "setBreakpoint" + Data any `json:"data,omitempty"` } func NewDebugger() *Debugger { @@ -98,18 +98,16 @@ func validateTargetPath(path string) (string, error) { func (d *Debugger) StartWithDebug(path string) { // Lock this goroutine to the current OS thread. - // Linux ptrace requires that all ptrace calls for a given traced process - // originate from the same OS thread that performed the initial attach. - // Without this, the Go scheduler may migrate the goroutine to a different - // OS thread, causing ptrace calls to fail with ESRCH ("no such process"). + // Linux ptrace requires that all ptrace calls for a given traced process originate from the same OS thread that performed the initial attach. + // Without this, the Go scheduler may migrate the goroutine to a different OS thread, causing ptrace calls to fail with ESRCH ("no such process"). runtime.LockOSThread() defer runtime.UnlockOSThread() // Validate and sanitise the user-supplied path before passing it to exec. validatedPath, err := validateTargetPath(path) if err != nil { - log.Printf("Rejected target path %q: %v", path, err) - return + log.Printf("[Debugger] Rejected target path %q: %v", path, err) + panic(err) } // Set up target for execution @@ -120,20 +118,20 @@ func (d *Debugger) StartWithDebug(path string) { cmd.SysProcAttr = &syscall.SysProcAttr{Ptrace: true} if err := cmd.Start(); err != nil { - log.Printf("Failed to start target: %v", err) + log.Printf("[Debugger] Failed to start target: %v", err) panic(err) } dbInf, err := debuginfo.NewDebugInfo(validatedPath, cmd.Process.Pid) if err != nil { - log.Printf("Failed to create debug info: %v", err) + log.Printf("[Debugger] Failed to create debug info: %v", err) panic(err) } - log.Printf("Started process with PID: %d and PGID: %d\n", dbInf.Target.PID, dbInf.Target.PGID) + log.Printf("[Debugger] Started process with PID: %d and PGID: %d\n", dbInf.Target.PID, dbInf.Target.PGID) // Enable tracking threads spawned from target and killing target once Bingo exits if err := syscall.PtraceSetOptions(dbInf.Target.PID, syscall.PTRACE_O_TRACECLONE|ptraceOExitKill); err != nil { - log.Printf("Failed to set TRACECLONE and EXITKILL options on target: %v", err) + log.Printf("[Debugger] Failed to set TRACECLONE and EXITKILL options on target: %v", err) panic(err) } @@ -141,8 +139,10 @@ func (d *Debugger) StartWithDebug(path string) { // We want to catch the initial SIGTRAP sent by process creation. When this is caught, we know that the target just started and we can ask the user where they want to set their breakpoints // The message we print to the console will be removed in the future, it's just for debugging purposes for now. - if err := cmd.Wait(); err != nil { - log.Printf("Received SIGTRAP from process creation: %v", err) + + var waitStatus syscall.WaitStatus + if _, status := syscall.Wait4(d.DebugInfo.Target.PID, &waitStatus, 0, nil); status != nil { + log.Printf("[Debugger] Received SIGTRAP from process creation: %v", status) } // Set initial breakpoints while the process is stopped at the initial SIGTRAP @@ -151,17 +151,17 @@ func (d *Debugger) StartWithDebug(path string) { // Check if we were stopped during initial breakpoint select { case <-d.EndDebugSession: - log.Println("Debug session ended during initial breakpoint, cleaning up") + log.Println("[Debugger] Debug session ended during initial breakpoint, cleaning up") return default: // Continue to debug loop } - log.Println("STARTING DEBUG LOOP") + log.Println("[Debugger] STARTING DEBUG LOOP") - d.debug() + d.mainDebugLoop() - log.Println("Debug loop ended, signaling completion") + log.Println("[Debugger] Debug loop ended") } @@ -169,30 +169,30 @@ func (d *Debugger) Continue(pid int) { // Read registers var regs syscall.PtraceRegs if err := syscall.PtraceGetRegs(pid, ®s); err != nil { - log.Printf("Failed to get registers: %v", err) - return // Process likely exited, gracefully return + log.Printf("[Debugger] Failed to get registers: %v", err) + panic(err) } - filename, line, fn := d.DebugInfo.PCToLine(regs.Rip - 1) // Breakpoint advances PC by 1 on x86, so we need to rewind - fmt.Printf("Stopped at %s at %d in %s\n", fn.Name, line, filename) + _, line, _ := d.DebugInfo.PCToLine(regs.Rip - 1) // Breakpoint advances PC by 1 on x86, so we need to rewind + //fmt.Printf("[Debugger] Stopped at %s at %d in %s\n", fn.Name, line, filename) // Remove the breakpoint bpAddr := regs.Rip - 1 - if err := d.ClearBreakpoint(line); err != nil { - log.Printf("Failed to clear breakpoint: %v", err) + if err := d.ClearBreakpoint(pid, line); err != nil { + log.Printf("[Debugger] Failed to clear breakpoint: %v", err) panic(err) } regs.Rip = bpAddr // Rewind Rip by 1 if err := syscall.PtraceSetRegs(pid, ®s); err != nil { - log.Printf("Failed to restore registers: %v", err) + log.Printf("[Debugger] Failed to restore registers: %v", err) panic(err) } // Step over the instruction we previously removed to put the breakpoint - // TODO: decide if we want to call debugger.SingleStep() for this or just the system call + // TODO: decide if we want to call debugger.SingleStep() for this or just the system if err := syscall.PtraceSingleStep(pid); err != nil { - log.Printf("Failed to single-step: %v", err) + log.Printf("[Debugger] Failed to single-step: %v", err) panic(err) } @@ -200,20 +200,20 @@ func (d *Debugger) Continue(pid int) { var waitStatus syscall.WaitStatus // Wait until the program lets us know we stepped over (handle cases where we get another signal which Wait4 would consume) if _, err := syscall.Wait4(pid, &waitStatus, 0, nil); err != nil { - log.Printf("Failed to wait for the single-step: %v", err) + log.Printf("[Debugger] Failed to wait for the single-step: %v", err) panic(err) } // Put the breakpoint back - if err := d.SetBreakpoint(line); err != nil { - log.Printf("Failed to set breakpoint: %v", err) + if err := d.SetBreakpoint(pid, line); err != nil { + log.Printf("[Debugger] Failed to set breakpoint: %v", err) panic(err) } // Resume execution // TODO: decide if we want to call debugger.Continue() for this or just the system call if err := syscall.PtraceCont(pid, 0); err != nil { - log.Printf("Failed to resume target execution: %v", err) + log.Printf("[Debugger] Failed to resume target execution: %v", err) panic(err) } @@ -222,7 +222,7 @@ func (d *Debugger) Continue(pid int) { func (d *Debugger) SingleStep(pid int) { if err := syscall.PtraceSingleStep(pid); err != nil { - log.Printf("Failed to single-step: %v", err) + log.Printf("[Debugger] Failed to single-step: %v", err) panic(err) } @@ -231,57 +231,60 @@ func (d *Debugger) SingleStep(pid int) { func (d *Debugger) StopDebug() { // Detach from the target process, letting it continue running if d.DebugInfo.Target.PID > 0 { - log.Printf("Detaching from target process (PID: %d)", d.DebugInfo.Target.PID) + log.Printf("[Debugger] Detaching from target process (PID: %d)", d.DebugInfo.Target.PID) if err := syscall.PtraceDetach(d.DebugInfo.Target.PID); err != nil { - log.Printf("Failed to detach from target process: %v (might have already exited)", err) + log.Printf("[Debugger] Failed to detach from target process: %v (might have already exited)", err) + panic(err) } } // Signal the debug loop to exit select { case d.EndDebugSession <- true: default: - // Channel might be full, that's ok + // Channel might be full, meaning debug session end already triggered } } -func (d *Debugger) SetBreakpoint(line int) error { +func (d *Debugger) SetBreakpoint(pid int, line int) error { pc, _, err := d.DebugInfo.LineToPC(d.DebugInfo.Target.Path, line) if err != nil { - log.Printf("Failed to get PC of line %v: %v", line, err) - panic(err) + return fmt.Errorf("failed to get PC of line %v: %v", line, err) } original := make([]byte, len(bpCode)) - if _, err := syscall.PtracePeekData(d.DebugInfo.Target.PID, uintptr(pc), original); err != nil { - return fmt.Errorf("failed to read original machine code into memory: %v for PID: %d", err, d.DebugInfo.Target.PID) + if _, err := syscall.PtracePeekData(pid, uintptr(pc), original); err != nil { + return fmt.Errorf("failed to read original machine code into memory: %v for PID: %d", err, pid) } - if _, err := syscall.PtracePokeData(d.DebugInfo.Target.PID, uintptr(pc), bpCode); err != nil { + if _, err := syscall.PtracePokeData(pid, uintptr(pc), bpCode); err != nil { return fmt.Errorf("failed to write breakpoint into memory: %v", err) } d.Breakpoints[pc] = original return nil } -func (d *Debugger) ClearBreakpoint(line int) error { +func (d *Debugger) ClearBreakpoint(pid int, line int) error { pc, _, err := d.DebugInfo.LineToPC(d.DebugInfo.Target.Path, line) if err != nil { - log.Printf("Failed to get PC of line %v: %v", line, err) - panic(err) + return fmt.Errorf("failed to get PC of line %v: %v", line, err) } - if _, err := syscall.PtracePokeData(d.DebugInfo.Target.PID, uintptr(pc), d.Breakpoints[pc]); err != nil { - return fmt.Errorf("failed to write breakpoint ndinto memory: %v", err) + // if _, err := syscall.PtracePokeData(d.DebugInfo.Target.PID, uintptr(pc), d.Breakpoints[pc]); err != nil { + // return fmt.Errorf("failed to write breakpoint into memory: %v", err) + // } + if _, err := syscall.PtracePokeData(pid, uintptr(pc), d.Breakpoints[pc]); err != nil { + return fmt.Errorf("failed to write breakpoint into memory: %v", err) } return nil } -func (d *Debugger) debug() { +// TODO: pass the correct pid to the debugger methods, keep an eye on this +func (d *Debugger) mainDebugLoop() { for { // Check if we should stop debugging select { case <-d.EndDebugSession: - log.Println("Debug session ending, exiting debug loop") + log.Println("[Debugger] Debug session ending, exiting debug loop") return default: // Continue with wait @@ -291,30 +294,29 @@ func (d *Debugger) debug() { var waitStatus syscall.WaitStatus wpid, err := syscall.Wait4(-1*d.DebugInfo.Target.PGID, &waitStatus, syscall.WNOHANG, nil) if err != nil { - log.Printf("Failed to wait for the target or any of its threads: %v", err) + log.Printf("[Debugger] Failed to wait for the target or any of its threads: %v", err) // Don't panic, just exit gracefully return } - // No process state changed yet - if wpid == 0 { - // Sleep briefly to avoid busy waiting + // TODO: change 10ms polling approach to goroutine + if wpid == 0 { // if no process state changed, sleep briefly to avoid busy waiting and consuming 100% cpu time.Sleep(10 * time.Millisecond) continue } if waitStatus.Exited() { if wpid == d.DebugInfo.Target.PID { // If target exited, terminate - log.Printf("Target %v execution completed", d.DebugInfo.Target.Path) + log.Printf("[Debugger] Target %v execution completed", d.DebugInfo.Target.Path) // Signal the end of debug session to hub select { case d.EndDebugSession <- true: default: - // Channel might be full or already closed, that's ok + // Channel might be full, meaning debug session end already triggered } return } else { - log.Printf("Thread exited with PID: %v", wpid) + log.Printf("[Debugger] Thread exited with PID: %v", wpid) } } else { // Only stop on breakpoints caused by our debugger, ignore any other event like spawning of new threads @@ -323,17 +325,9 @@ func (d *Debugger) debug() { d.breakpointHit(wpid) - // Check if we were signaled to stop during breakpoint handling - select { - case <-d.EndDebugSession: - log.Println("Debug session ending after breakpoint handling") - return - default: - } - } else { if err := syscall.PtraceCont(wpid, 0); err != nil { - log.Printf("Failed to resume target execution: %v for PID: %d", err, wpid) + log.Printf("[Debugger] Failed to resume target execution: %v for PID: %d", err, wpid) // Don't panic, might have been detached return } @@ -342,6 +336,7 @@ func (d *Debugger) debug() { } } +// TODO: maybe refactor later func (d *Debugger) initialBreakpointHit() { // Create initial breakpoint event event := InitialBreakpointHitEvent{ @@ -349,43 +344,44 @@ func (d *Debugger) initialBreakpointHit() { } // Send initial breakpoint hit event to hub - log.Println("Initial breakpoint hit, debugger ready for commands") + log.Println("[Debugger] Initial breakpoint hit, debugger ready for commands") d.InitialBreakpointHit <- event // Wait for commands from hub (typically to set breakpoints) for { select { case cmd := <-d.DebugCommand: - log.Printf("Initial state - received command: %s", cmd.Type) + log.Printf("[Debugger] Received command: %s", cmd.Type) switch cmd.Type { case "setBreakpoint": if data, ok := cmd.Data.(map[string]any); ok { if line, ok := data["line"].(int); ok { - if err := d.SetBreakpoint(int(line)); err != nil { - log.Printf("Failed to set breakpoint at line %d: %v", int(line), err) + if err := d.SetBreakpoint(d.DebugInfo.Target.PID, int(line)); err != nil { + log.Printf("[Debugger] Failed to set breakpoint at line %d: %v", int(line), err) + panic(err) } else { - log.Printf("Breakpoint set at line %d while at breakpoint", int(line)) + log.Printf("[Debugger] Breakpoint set at line %d while at breakpoint", int(line)) } } } case "continue": - log.Println("Continuing from initial breakpoint") + log.Println("[Debugger] Continuing from initial breakpoint") if err := syscall.PtraceCont(d.DebugInfo.Target.PID, 0); err != nil { - log.Printf("Failed to resume target execution: %v", err) + log.Printf("[Debugger] Failed to resume target execution: %v", err) panic(err) } return // Exit initial breakpoint handling case "step": - log.Println("Cannot single-step from initial breakpoint") + log.Println("[Debugger] Cannot single-step from initial breakpoint") case "quit": d.StopDebug() return default: - log.Printf("Unknown command during initial breakpoint: %s", cmd.Type) + log.Printf("[Debugger] Unknown command during initial breakpoint: %s", cmd.Type) } case <-d.EndDebugSession: - log.Println("Debug session ending during initial breakpoint") + log.Println("[Debugger] Debug session ending during initial breakpoint") return } } @@ -395,8 +391,8 @@ func (d *Debugger) breakpointHit(pid int) { // Get register information to determine location var regs syscall.PtraceRegs if err := syscall.PtraceGetRegs(pid, ®s); err != nil { - log.Printf("Failed to get registers: %v", err) - return + log.Printf("[Debugger] Failed to get registers: %v", err) + panic(err) } // Get location information @@ -411,13 +407,13 @@ func (d *Debugger) breakpointHit(pid int) { } // Send breakpoint hit event to hub - log.Printf("Breakpoint hit at %s:%d in %s, waiting for command", filename, line, fn.Name) + log.Printf("[Debugger] Breakpoint hit at %s:%d in %s, waiting for command", filename, line, fn.Name) d.BreakpointHit <- event // Wait for command from hub select { case cmd := <-d.DebugCommand: - log.Printf("Received command: %s", cmd.Type) + log.Printf("[Debugger] Received command: %s", cmd.Type) switch cmd.Type { case "continue": d.Continue(pid) @@ -426,10 +422,10 @@ func (d *Debugger) breakpointHit(pid int) { case "setBreakpoint": if data, ok := cmd.Data.(map[string]any); ok { if line, ok := data["line"].(int); ok { - if err := d.SetBreakpoint(int(line)); err != nil { - log.Printf("Failed to set breakpoint at line %d: %v", int(line), err) + if err := d.SetBreakpoint(d.DebugInfo.Target.PID, int(line)); err != nil { + log.Printf("[Debugger] Failed to set breakpoint at line %d: %v", int(line), err) } else { - log.Printf("Breakpoint set at line %d while at breakpoint", int(line)) + log.Printf("[Debugger] Breakpoint set at line %d while at breakpoint", int(line)) } } } @@ -437,11 +433,11 @@ func (d *Debugger) breakpointHit(pid int) { d.StopDebug() return default: - log.Printf("Unknown command: %s", cmd.Type) + log.Printf("[Debugger] Unknown command: %s", cmd.Type) d.Continue(pid) // Default to continue } case <-d.EndDebugSession: - log.Println("Debug session ending, stopping breakpoint handler") + log.Println("[Debugger] Debug session ending, stopping breakpoint handler") return } } diff --git a/internal/debuginfo/debug_info.go b/internal/debuginfo/debug_info.go index 58ad901..ad3896e 100644 --- a/internal/debuginfo/debug_info.go +++ b/internal/debuginfo/debug_info.go @@ -20,7 +20,6 @@ type DebugInfo struct { } func NewDebugInfo(path string, pid int) (*DebugInfo, error) { - exe, err := elf.Open(path) if err != nil { return nil, fmt.Errorf("failed to open target ELF file: %v", err) diff --git a/internal/ws/connection.go b/internal/ws/connection.go index 1d0a63e..905c792 100644 --- a/internal/ws/connection.go +++ b/internal/ws/connection.go @@ -38,7 +38,7 @@ func (c *Connection) ReadPump() { if err != nil { // Only log truly unexpected errors, not normal client disconnects if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure, websocket.CloseAbnormalClosure) { - log.Printf("Connection %s unexpected close: %v", c.id, err) + log.Printf("[Connection] Connection %s unexpected close: %v", c.id, err) } break } @@ -51,7 +51,7 @@ func (c *Connection) WritePump() { for message := range c.send { if err := c.conn.WriteJSON(message); err != nil { - log.Printf("Connection %s write error: %v", c.id, err) + log.Printf("[Connection] Connection %s write error: %v", c.id, err) return } } @@ -60,7 +60,7 @@ func (c *Connection) WritePump() { if err := c.conn.WriteMessage(websocket.CloseMessage, []byte{}); err != nil { // Don't log if connection was already closed if !isConnectionClosedError(err) { - log.Printf("Connection %s: failed to send close message: %v", c.id, err) + log.Printf("[Connection] Connection %s: failed to send close message: %v", c.id, err) } } } @@ -70,7 +70,7 @@ func (c *Connection) closeConn() { if err := c.conn.Close(); err != nil { // Don't log if connection was already closed if !isConnectionClosedError(err) { - log.Printf("Connection %s close error: %v", c.id, err) + log.Printf("[Connection] Connection %s close error: %v", c.id, err) } } }) diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 4125e0c..0a522bb 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -61,7 +61,7 @@ func (h *Hub) Run() { // Check idle timeout if h.idleTimeout > 0 && len(h.connections) == 0 { if time.Since(h.lastActivity) > h.idleTimeout { - log.Printf("Session %s idle for %v, shutting down", h.sessionID, h.idleTimeout) + log.Printf("[Hub] Session %s idle for %v, shutting down", h.sessionID, h.idleTimeout) h.shutdown() return } @@ -72,23 +72,22 @@ func (h *Hub) Run() { h.connections[client] = struct{}{} h.lastActivity = time.Now() h.mu.Unlock() - log.Printf("Client %s connected to hub %s (%d total)", client.id, h.sessionID, len(h.connections)) + log.Printf("[Hub] Client %s connected to hub %s (%d total)", client.id, h.sessionID, len(h.connections)) case client := <-h.unregister: h.mu.Lock() if _, ok := h.connections[client]; ok { delete(h.connections, client) client.CloseSend() - log.Printf("Client %s disconnected from hub %s (%d remaining)", client.id, h.sessionID, len(h.connections)) + log.Printf("[Hub] Client %s disconnected from hub %s (%d remaining)", client.id, h.sessionID, len(h.connections)) // When last client leaves, shutdown hub if len(h.connections) == 0 { h.mu.Unlock() - log.Printf("Session %s has no clients, shutting down hub", h.sessionID) + log.Printf("[Hub] Session %s has no clients, shutting down hub", h.sessionID) h.shutdown() return } - } h.mu.Unlock() @@ -105,16 +104,16 @@ func (h *Hub) Run() { } h.mu.RUnlock() for _, connection := range slowConnections { - log.Printf("Connection %s is slow; unregistering from hub %s", connection.id, h.sessionID) + log.Printf("[Hub] Connection %s is slow; unregistering from hub %s", connection.id, h.sessionID) h.Unregister(connection) } case cmd := <-h.commands: - log.Printf("Hub %s command: %s", h.sessionID, cmd.Type) + log.Printf("[Hub] Hub %s command: %s", h.sessionID, cmd.Type) h.handleCommand(cmd) case <-h.debugger.EndDebugSession: - log.Printf("Debugger signaled end of session %s, shutting down hub", h.sessionID) + log.Printf("[Hub] Debugger signaled end of session %s, shutting down hub", h.sessionID) h.shutdown() } } @@ -148,7 +147,7 @@ func (h *Hub) listenForDebuggerEvents() { for { select { case bpEvent := <-h.debugger.BreakpointHit: - log.Printf("[Debugger Event] Breakpoint hit at %s:%d in %s", bpEvent.Filename, bpEvent.Line, bpEvent.Function) + log.Printf("[Hub] [Debugger Event] Breakpoint hit at %s:%d in %s", bpEvent.Filename, bpEvent.Line, bpEvent.Function) // Create and send breakpoint hit event to all clients event := BreakpointHitEvent{ @@ -162,7 +161,7 @@ func (h *Hub) listenForDebuggerEvents() { eventData, err := json.Marshal(event) if err != nil { - log.Printf("Failed to marshal breakpoint event: %v", err) + log.Printf("[Hub] Failed to marshal breakpoint event: %v", err) continue } @@ -182,7 +181,7 @@ func (h *Hub) listenForDebuggerEvents() { stateData, err := json.Marshal(stateEvent) if err != nil { - log.Printf("Failed to marshal state update event: %v", err) + log.Printf("[Hub] Failed to marshal state update event: %v", err) continue } @@ -194,7 +193,7 @@ func (h *Hub) listenForDebuggerEvents() { h.Broadcast(stateMessage) case initialBpEvent := <-h.debugger.InitialBreakpointHit: - log.Printf("[Debugger Event] Initial breakpoint hit (PID: %d, session: %s)", initialBpEvent.PID, h.sessionID) + log.Printf("[Hub] [Debugger Event] Initial breakpoint hit (PID: %d, session: %s)", initialBpEvent.PID, h.sessionID) // Create and send initial breakpoint event to all clients event := InitialBreakpointHitEvent{ @@ -205,7 +204,7 @@ func (h *Hub) listenForDebuggerEvents() { eventData, err := json.Marshal(event) if err != nil { - log.Printf("Failed to marshal initial breakpoint event: %v", err) + log.Printf("[Hub] Failed to marshal initial breakpoint event: %v", err) continue } @@ -217,7 +216,7 @@ func (h *Hub) listenForDebuggerEvents() { h.Broadcast(message) // Also send state update to indicate we're at a breakpoint - log.Printf("[State Change] Transitioning to breakpoint state (session: %s)", h.sessionID) + log.Printf("[Hub] [State Change] Transitioning to breakpoint state (session: %s)", h.sessionID) stateEvent := StateUpdateEvent{ Type: EventStateUpdate, SessionID: h.sessionID, @@ -226,7 +225,7 @@ func (h *Hub) listenForDebuggerEvents() { stateData, err := json.Marshal(stateEvent) if err != nil { - log.Printf("Failed to marshal state update event: %v", err) + log.Printf("[Hub] Failed to marshal state update event: %v", err) continue } @@ -238,7 +237,7 @@ func (h *Hub) listenForDebuggerEvents() { h.Broadcast(stateMessage) case <-h.debugger.EndDebugSession: - log.Println("Debugger event listener ending, sending state update to ready") + log.Println("[Hub] Debugger event listener ending, sending state update to ready") h.sendStateUpdate(StateReady) return } @@ -247,24 +246,14 @@ func (h *Hub) listenForDebuggerEvents() { // Forward commands from client to debugger func (h *Hub) handleCommand(cmd Message) { - if h.debugger == nil { - log.Printf("No debugger attached to hub %s, ignoring command", h.sessionID) - return - } - switch CommandType(cmd.Type) { case CmdStartDebug: var startDebugCmd StartDebugCmd if err := json.Unmarshal(cmd.Data, &startDebugCmd); err != nil { - log.Printf("Failed to unmarshal startDebug command: %v", err) + log.Printf("[Hub] Failed to unmarshal startDebug command: %v", err) return } - log.Printf("[Command] StartDebug received: %s (session: %s)", startDebugCmd.TargetPath, h.sessionID) - - // Create a new debugger instance for this debug session - h.mu.Lock() - h.debugger = debugger.NewDebugger() - h.mu.Unlock() + log.Printf("[Hub] [Command] StartDebug received: %s (session: %s)", startDebugCmd.TargetPath, h.sessionID) // Start listening for events from this new debugger go h.listenForDebuggerEvents() @@ -275,20 +264,10 @@ func (h *Hub) handleCommand(cmd Message) { case CmdContinue: var continueCmd ContinueCmd if err := json.Unmarshal(cmd.Data, &continueCmd); err != nil { - log.Printf("Failed to unmarshal continue command: %v", err) - return - } - log.Printf("[Command] Continue received (session: %s)", h.sessionID) - - h.mu.RLock() - if h.debugger == nil { - h.mu.RUnlock() - log.Printf("[Error] No active debug session to continue (session: %s)", h.sessionID) + log.Printf("[Hub] Failed to unmarshal continue command: %v", err) return } - h.mu.RUnlock() - - log.Printf("[State Change] Transitioning to executing state (session: %s)", h.sessionID) + log.Printf("[Hub] [Command] Continue received (session: %s)", h.sessionID) // Send executing state update h.sendStateUpdate(StateExecuting) @@ -297,30 +276,15 @@ func (h *Hub) handleCommand(cmd Message) { debugCmd := debugger.DebugCommand{ Type: "continue", } - select { - case h.debugger.DebugCommand <- debugCmd: - log.Printf("[Command] Continue command sent to debugger (session: %s)", h.sessionID) - default: - log.Printf("[Error] Failed to send continue command to debugger - channel full") - } + h.sendCommandToDebugger(debugCmd, "Continue") case CmdStepOver: var stepOverCmd StepOverCmd if err := json.Unmarshal(cmd.Data, &stepOverCmd); err != nil { - log.Printf("Failed to unmarshal stepOver command: %v", err) + log.Printf("[Hub] Failed to unmarshal stepOver command: %v", err) return } - log.Printf("[Command] StepOver received (session: %s)", h.sessionID) - - h.mu.RLock() - if h.debugger == nil { - h.mu.RUnlock() - log.Printf("[Error] No active debug session to step (session: %s)", h.sessionID) - return - } - h.mu.RUnlock() - - log.Printf("[State Change] Transitioning to executing state (session: %s)", h.sessionID) + log.Printf("[Hub] [Command] StepOver received (session: %s)", h.sessionID) // Send executing state update h.sendStateUpdate(StateExecuting) @@ -329,82 +293,50 @@ func (h *Hub) handleCommand(cmd Message) { debugCmd := debugger.DebugCommand{ Type: "step", } - select { - case h.debugger.DebugCommand <- debugCmd: - log.Printf("[Command] StepOver command sent to debugger (session: %s)", h.sessionID) - default: - log.Printf("[Error] Failed to send step command to debugger - channel full") - } + h.sendCommandToDebugger(debugCmd, "StepOver") case CmdSetBreakpoint: var setBreakpointCmd SetBreakpointCmd if err := json.Unmarshal(cmd.Data, &setBreakpointCmd); err != nil { - log.Printf("Failed to unmarshal setBreakpoint command: %v", err) - return - } - log.Printf("[Command] SetBreakpoint received: %s:%d (session: %s)", setBreakpointCmd.Filename, setBreakpointCmd.Line, h.sessionID) - - h.mu.RLock() - if h.debugger == nil { - h.mu.RUnlock() - log.Printf("[Error] No active debug session to set breakpoint (session: %s)", h.sessionID) + log.Printf("[Hub] Failed to unmarshal setBreakpoint command: %v", err) return } - h.mu.RUnlock() + log.Printf("[Hub] [Command] SetBreakpoint received: %s:%d (session: %s)", setBreakpointCmd.Filename, setBreakpointCmd.Line, h.sessionID) // Send command to debugger debugCmd := debugger.DebugCommand{ Type: "setBreakpoint", - Data: map[string]interface{}{ + Data: map[string]any{ "line": setBreakpointCmd.Line, "filename": setBreakpointCmd.Filename, }, } - select { - case h.debugger.DebugCommand <- debugCmd: - log.Printf("[Command] SetBreakpoint command sent to debugger (session: %s)", h.sessionID) - default: - log.Printf("[Error] Failed to send set breakpoint command to debugger - channel full") - } + h.sendCommandToDebugger(debugCmd, "SetBreakpoint") case CmdExit: var exitCmd ExitCmd if err := json.Unmarshal(cmd.Data, &exitCmd); err != nil { - log.Printf("Failed to unmarshal exit command: %v", err) + log.Printf("[Hub] Failed to unmarshal exit command: %v", err) return } - log.Printf("[Command] Exit received (session: %s)", h.sessionID) + log.Printf("[Hub] [Command] Exit received (session: %s)", h.sessionID) - h.mu.RLock() - if h.debugger == nil { - h.mu.RUnlock() - log.Printf("[Info] No active debug session to stop (session: %s)", h.sessionID) - // Still send state update to ready in case client is confused - h.sendStateUpdate(StateReady) - return - } - h.mu.RUnlock() + h.sendStateUpdate(StateReady) // Send command to debugger debugCmd := debugger.DebugCommand{ Type: "quit", } - select { - case h.debugger.DebugCommand <- debugCmd: - log.Printf("[Command] Exit command sent to debugger (session: %s)", h.sessionID) - default: - log.Printf("[Error] Failed to send quit command to debugger - channel full") - } - // Note: State update to 'ready' will be sent by listenForDebuggerEvents when EndDebugSession is received + h.sendCommandToDebugger(debugCmd, "Exit") default: - log.Printf("[Error] Unknown command type: %s", cmd.Type) + log.Printf("[Hub] [Error] Unknown command type: %s", cmd.Type) } } // Helper method to send state updates func (h *Hub) sendStateUpdate(state State) { - log.Printf("[State Update] Broadcasting state '%s' to all clients (session: %s)", state, h.sessionID) + log.Printf("[Hub] [State Update] Broadcasting state '%s' to all clients (session: %s)", state, h.sessionID) stateEvent := StateUpdateEvent{ Type: EventStateUpdate, SessionID: h.sessionID, @@ -413,7 +345,7 @@ func (h *Hub) sendStateUpdate(state State) { stateData, err := json.Marshal(stateEvent) if err != nil { - log.Printf("Failed to marshal state update event: %v", err) + log.Printf("[Hub] Failed to marshal state update event: %v", err) return } @@ -424,3 +356,13 @@ func (h *Hub) sendStateUpdate(state State) { h.Broadcast(stateMessage) } + +// Helper method to send commands to debugger +func (h *Hub) sendCommandToDebugger(cmd debugger.DebugCommand, cmdName string) { + select { + case h.debugger.DebugCommand <- cmd: + log.Printf("[Hub] [Command] %s command sent to debugger (session: %s)", cmdName, h.sessionID) + default: + log.Printf("[Hub] [Error] Failed to send %s command to debugger - channel full", cmdName) + } +} diff --git a/internal/ws/server.go b/internal/ws/server.go index ed669d5..bba78c0 100644 --- a/internal/ws/server.go +++ b/internal/ws/server.go @@ -14,11 +14,10 @@ import ( ) type Server struct { - addr string - hubs map[string]*Hub - config config.WebSocketConfig - mu sync.RWMutex - KillServer chan bool + addr string + hubs map[string]*Hub + config config.WebSocketConfig + mu sync.RWMutex } func NewServer(addr string, cfg *config.WebSocketConfig) *Server { @@ -30,18 +29,18 @@ func NewServer(addr string, cfg *config.WebSocketConfig) *Server { func newServerWithConfig(addr string, cfg config.WebSocketConfig) *Server { s := &Server{ - addr: addr, - hubs: make(map[string]*Hub), - config: cfg, - KillServer: make(chan bool), + addr: addr, + hubs: make(map[string]*Hub), + config: cfg, } - http.HandleFunc("/ws/", s.serveWebSocket) + + http.HandleFunc("/ws/", s.getOrCreateSession) http.HandleFunc("/sessions", s.getSessions) return s } func (s *Server) Serve() error { - log.Printf("Bingo WebSocket server on %s", s.addr) + log.Printf("[Server] Bingo WebSocket server on %s", s.addr) return http.ListenAndServe(s.addr, nil) } @@ -56,35 +55,50 @@ func (s *Server) getSessions(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(sessions); err != nil { - log.Printf("Error encoding sessions: %v", err) + log.Printf("[Server] Error encoding sessions: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } -func (s *Server) serveWebSocket(w http.ResponseWriter, r *http.Request) { +func (s *Server) getOrCreateSession(w http.ResponseWriter, r *http.Request) { upgrader := websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } conn, err := upgrader.Upgrade(w, r, nil) if err != nil { - log.Printf("WebSocket upgrade failed: %v", err) + log.Printf("[Server] WebSocket upgrade failed: %v", err) return } sessionID := r.URL.Query().Get("session") - if sessionID == "" { - log.Println("No session ID provided, generating...") + sessionProvided := sessionID != "" + if !sessionProvided { sessionID = uuid.New().String() + log.Printf("[Server] No session ID provided, generating new session ID: %v", sessionID) } - hub, err := s.GetOrCreateHub(sessionID) - if err != nil { - log.Printf("Unable to create hub for session %s: %v", sessionID, err) - if err := conn.Close(); err != nil { - log.Printf("WebSocket close error: %v", err) + var hub *Hub + if sessionProvided { + // Only get existing hub if session ID was provided by client + hub, err = s.GetHub(sessionID) + if err != nil { + log.Printf("[Server] Session %s not found: %v", sessionID, err) + if err := conn.Close(); err != nil { + log.Printf("[Server] WebSocket close error: %v", err) + } + return + } + } else { + // Create new hub only for server-generated session IDs + hub, err = s.CreateHub(sessionID) + if err != nil { + log.Printf("[Server] Unable to create hub for session %s: %v, shutting down...", sessionID, err) + if err := conn.Close(); err != nil { + log.Printf("[Server] WebSocket close error: %v", err) + } + return } - return } client := NewConnection(conn, hub, r.RemoteAddr) @@ -99,7 +113,7 @@ func (s *Server) serveWebSocket(w http.ResponseWriter, r *http.Request) { } data, err := json.Marshal(ack) if err != nil { - log.Printf("Failed to marshal sessionStarted: %v", err) + log.Printf("[Server] Failed to marshal sessionStarted: %v", err) return } client.send <- Message{ @@ -108,28 +122,41 @@ func (s *Server) serveWebSocket(w http.ResponseWriter, r *http.Request) { } } -func (s *Server) GetOrCreateHub(sessionID string) (*Hub, error) { +// GetHub retrieves an existing hub for the given session ID. +func (s *Server) GetHub(sessionID string) (*Hub, error) { s.mu.RLock() hub, exists := s.hubs[sessionID] s.mu.RUnlock() if !exists { - s.mu.Lock() - // Check max sessions limit - if s.config.MaxSessions > 0 && len(s.hubs) >= s.config.MaxSessions { - s.mu.Unlock() - log.Printf("Max sessions (%d) reached, rejecting session: %s", s.config.MaxSessions, sessionID) - return nil, fmt.Errorf("max sessions (%d) reached", s.config.MaxSessions) - } + return nil, fmt.Errorf("session not found: %s", sessionID) + } + return hub, nil +} - d := debugger.NewDebugger() - hub = NewHub(sessionID, s.config.IdleTimeout, d) - hub.onShutdown = s.removeHub // Set callback for cleanup - s.hubs[sessionID] = hub - go hub.Run() - s.mu.Unlock() - log.Printf("Created hub for session: %s", sessionID) +// CreateHub creates a new hub for the given session ID. +func (s *Server) CreateHub(sessionID string) (*Hub, error) { + s.mu.Lock() + defer s.mu.Unlock() + + // Check if hub already exists + if _, exists := s.hubs[sessionID]; exists { + return nil, fmt.Errorf("session already exists: %s", sessionID) } + + // Check max sessions limit + if s.config.MaxSessions > 0 && len(s.hubs) >= s.config.MaxSessions { + log.Printf("[Server] Max sessions (%d) reached, rejecting session: %s", s.config.MaxSessions, sessionID) + return nil, fmt.Errorf("max sessions (%d) reached", s.config.MaxSessions) + } + + d := debugger.NewDebugger() + hub := NewHub(sessionID, s.config.IdleTimeout, d) + hub.onShutdown = s.removeHub // Set callback for cleanup + s.hubs[sessionID] = hub + go hub.Run() + log.Printf("[Server] Created hub for session: %s", sessionID) + return hub, nil } @@ -137,18 +164,18 @@ func (s *Server) removeHub(sessionID string) { s.mu.Lock() delete(s.hubs, sessionID) s.mu.Unlock() - log.Printf("Removed hub for session: %s", sessionID) + log.Printf("[Server] Removed hub for session: %s", sessionID) } func (s *Server) Shutdown() { - log.Printf("Shutting down server, closing %d hub(s)", len(s.hubs)) + log.Printf("[Server] Shutting down server, closing %d hub(s)", len(s.hubs)) s.mu.Lock() defer s.mu.Unlock() // Close all hubs for sessionID, hub := range s.hubs { - log.Printf("Shutting down hub for session: %s", sessionID) + log.Printf("[Server] Shutting down hub for session: %s", sessionID) // Close all connections in the hub for c := range hub.connections { @@ -159,5 +186,5 @@ func (s *Server) Shutdown() { delete(s.hubs, sessionID) } - log.Println("All hubs and debuggers closed") + log.Println("[Server] All hubs and debuggers closed") } diff --git a/internal/ws/ws_test.go b/internal/ws/ws_test.go index d634c54..46e36e3 100644 --- a/internal/ws/ws_test.go +++ b/internal/ws/ws_test.go @@ -536,8 +536,8 @@ var _ = Describe("Server", func() { }) }) - Describe("GetOrCreateHub", func() { - It("should create and retrieve hubs", func() { + Describe("CreateHub", func() { + It("should create a new hub", func() { wsConfig := config.WebSocketConfig{ MaxSessions: 100, IdleTimeout: time.Minute, @@ -549,19 +549,80 @@ var _ = Describe("Server", func() { config: wsConfig, } - hub1, err := server.GetOrCreateHub("session-1") + hub1, err := server.CreateHub("session-1") Expect(err).NotTo(HaveOccurred()) Expect(hub1).NotTo(BeNil()) Expect(hub1.sessionID).To(Equal("session-1")) - hub2, err := server.GetOrCreateHub("session-1") + hub2, err := server.CreateHub("session-2") Expect(err).NotTo(HaveOccurred()) - Expect(hub2).To(Equal(hub1)) + Expect(hub2).NotTo(BeNil()) + Expect(hub2).NotTo(Equal(hub1)) + }) + + It("should return error if session already exists", func() { + wsConfig := config.WebSocketConfig{ + MaxSessions: 100, + IdleTimeout: time.Minute, + } + + server := &Server{ + addr: "localhost:0", + hubs: make(map[string]*Hub), + config: wsConfig, + } - hub3, err := server.GetOrCreateHub("session-2") + hub1, err := server.CreateHub("session-1") Expect(err).NotTo(HaveOccurred()) - Expect(hub3).NotTo(BeNil()) - Expect(hub3).NotTo(Equal(hub1)) + Expect(hub1).NotTo(BeNil()) + + // Try to create the same session again + hub2, err := server.CreateHub("session-1") + Expect(err).To(HaveOccurred()) + Expect(hub2).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("session already exists")) + }) + }) + + Describe("GetHub", func() { + It("should retrieve an existing hub", func() { + wsConfig := config.WebSocketConfig{ + MaxSessions: 100, + IdleTimeout: time.Minute, + } + + server := &Server{ + addr: "localhost:0", + hubs: make(map[string]*Hub), + config: wsConfig, + } + + hub1, err := server.CreateHub("session-1") + Expect(err).NotTo(HaveOccurred()) + Expect(hub1).NotTo(BeNil()) + + // Retrieve the same hub + hub2, err := server.GetHub("session-1") + Expect(err).NotTo(HaveOccurred()) + Expect(hub2).To(Equal(hub1)) + }) + + It("should return error if session does not exist", func() { + wsConfig := config.WebSocketConfig{ + MaxSessions: 100, + IdleTimeout: time.Minute, + } + + server := &Server{ + addr: "localhost:0", + hubs: make(map[string]*Hub), + config: wsConfig, + } + + hub, err := server.GetHub("nonexistent") + Expect(err).To(HaveOccurred()) + Expect(hub).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("session not found")) }) }) @@ -578,17 +639,17 @@ var _ = Describe("Server", func() { config: wsConfig, } - hub1, err := server.GetOrCreateHub("session-1") + hub1, err := server.CreateHub("session-1") Expect(err).NotTo(HaveOccurred()) hub1.onShutdown = server.removeHub Expect(hub1).NotTo(BeNil()) - hub2, err := server.GetOrCreateHub("session-2") + hub2, err := server.CreateHub("session-2") Expect(err).NotTo(HaveOccurred()) hub2.onShutdown = server.removeHub Expect(hub2).NotTo(BeNil()) - hub3, err := server.GetOrCreateHub("session-3") + hub3, err := server.CreateHub("session-3") Expect(err).To(HaveOccurred()) Expect(hub3).To(BeNil()) @@ -612,7 +673,7 @@ var _ = Describe("Server", func() { config: wsConfig, } - hub, err := server.GetOrCreateHub("session-1") + hub, err := server.CreateHub("session-1") Expect(err).NotTo(HaveOccurred()) Expect(hub).NotTo(BeNil()) @@ -638,7 +699,7 @@ var _ = Describe("Server", func() { config: wsConfig, } - server := httptest.NewServer(http.HandlerFunc(s.serveWebSocket)) + server := httptest.NewServer(http.HandlerFunc(s.getOrCreateSession)) defer server.Close() dialer := websocket.Dialer{} @@ -678,7 +739,7 @@ var _ = Describe("Server", func() { config: wsConfig, } - server := httptest.NewServer(http.HandlerFunc(s.serveWebSocket)) + server := httptest.NewServer(http.HandlerFunc(s.getOrCreateSession)) defer server.Close() resp, err := http.Get(server.URL + "/ws/?session=session-1") @@ -691,7 +752,7 @@ var _ = Describe("Server", func() { Expect(hubCount).To(Equal(0)) }) - It("should create hub and register connection", func() { + It("should reject connection with provided session ID that does not exist", func() { wsConfig := config.WebSocketConfig{ MaxSessions: 10, IdleTimeout: time.Minute, @@ -703,7 +764,46 @@ var _ = Describe("Server", func() { config: wsConfig, } - server := httptest.NewServer(http.HandlerFunc(s.serveWebSocket)) + server := httptest.NewServer(http.HandlerFunc(s.getOrCreateSession)) + defer server.Close() + + dialer := websocket.Dialer{} + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/?session=nonexistent" + conn, resp, _ := dialer.Dial(wsURL, nil) + if conn != nil { + _ = conn.Close() + } + if resp != nil { + _ = resp.Body.Close() + } + + // Either dial fails or connection is immediately closed + // Either way, no hub should be created + s.mu.RLock() + hubCount := len(s.hubs) + s.mu.RUnlock() + Expect(hubCount).To(Equal(0)) + }) + + It("should accept connection with provided session ID that exists", func() { + wsConfig := config.WebSocketConfig{ + MaxSessions: 10, + IdleTimeout: time.Minute, + } + + s := &Server{ + addr: "localhost:0", + hubs: make(map[string]*Hub), + config: wsConfig, + } + + // Pre-create the hub + hub, err := s.CreateHub("session-1") + Expect(err).NotTo(HaveOccurred()) + hub.onShutdown = s.removeHub + go hub.Run() + + server := httptest.NewServer(http.HandlerFunc(s.getOrCreateSession)) defer server.Close() dialer := websocket.Dialer{} diff --git a/pkg/client/client.go b/pkg/client/client.go index 39cd51a..d85fa67 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -6,7 +6,6 @@ import ( "fmt" "log" "net/url" - "strings" "sync" "github.com/bingosuite/bingo/internal/ws" @@ -177,10 +176,6 @@ func unmarshalJSON(data []byte, v any) error { func (c *Client) SendCommand(cmdType string, payload []byte) error { // TODO: decide which states allow which commands - currentState := c.State() - if currentState != ws.StateBreakpoint { - return fmt.Errorf("cannot send command '%s' in state '%s' (must be in 'breakpoint' state)", cmdType, currentState) - } msg := ws.Message{ Type: cmdType, Data: payload, @@ -229,18 +224,7 @@ func (c *Client) StartDebug(targetPath string) error { if err != nil { return err } - msg := ws.Message{ - Type: string(ws.CmdStartDebug), - Data: payload, - } - - select { - case c.send <- msg: - log.Printf("[Command] Sent %s command (state: %s)", msg.Type, c.State()) - return nil - case <-c.done: - return fmt.Errorf("connection closed") - } + return c.SendCommand(string(ws.CmdStartDebug), payload) } func (c *Client) Stop() error { @@ -252,19 +236,8 @@ func (c *Client) Stop() error { if err != nil { return err } - msg := ws.Message{ - Type: string(ws.CmdExit), - Data: payload, - } - - select { - case c.send <- msg: - log.Printf("[Command] Sent %s command (state: %s)", msg.Type, c.State()) - // State will be updated to 'ready' when server confirms debug session has stopped - return nil - case <-c.done: - return fmt.Errorf("connection closed") - } + // State will be updated to 'ready' when server confirms debug session has stopped + return c.SendCommand(string(ws.CmdExit), payload) } func (c *Client) SetBreakpoint(filename string, line int) error { @@ -340,12 +313,10 @@ func (c *Client) closeConn() { }) } +// TODO: refactor func isConnectionClosedError(err error) bool { if err == nil { return false } - errMsg := err.Error() - return websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) || - strings.Contains(errMsg, "use of closed network connection") || - strings.Contains(errMsg, "broken pipe") + return websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) }