Skip to content

Commit 746b551

Browse files
committed
feat: improve connection stability, session lifecycle, file transfer, and encoding robustness
- Fix connection stability: add WebSocket ping/pong heartbeat with SetReadDeadline/SetPongHandler on bridge, periodic server-side ping - Fix Ctrl+C graceful disconnect: signal handler now closes WebSocket connection before exiting, sending clean close frame to server - Fix tab close cleanup: add beforeunload + visibilitychange handlers using navigator.sendBeacon to disconnect bridge on tab close - Fix session expiry: add SESSION_TTL (default 10 min), enforce in command handler, expose expires_at/expired in API response - Fix base64 encoding: support base64url, strip whitespace, remove strict roundtrip validation, fallback to Latin-1 for non-UTF-8 data - Add file upload/download: HTTP multipart upload + stream download, WebSocket file_read/file_write protocol, 50MB file size limit - Add comprehensive tests: 24 server tests, 17 bridge tests all passing
1 parent 3cb5028 commit 746b551

7 files changed

Lines changed: 846 additions & 51 deletions

File tree

bridge/main.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"bytes"
5+
"encoding/base64"
56
"encoding/json"
67
"fmt"
78
"io"
@@ -25,6 +26,8 @@ const maxOutputBytes = 128 * 1024
2526
const (
2627
reconnectBaseDelay = 1 * time.Second
2728
reconnectMaxDelay = 30 * time.Second
29+
pongWait = 60 * time.Second
30+
maxFileSize = 50 * 1024 * 1024
2831
)
2932

3033
var sessionCodePattern = regexp.MustCompile(`^[0-9a-f]{12}$`)
@@ -42,6 +45,32 @@ func printLine(parts ...string) {
4245
_, _ = os.Stdout.WriteString(strings.Join(parts, "") + "\n")
4346
}
4447

48+
// Global reference for signal handler to gracefully close the connection
49+
var (
50+
currentBridge *wsConn
51+
currentBridgeMu sync.Mutex
52+
)
53+
54+
func setCurrentBridge(w *wsConn) {
55+
currentBridgeMu.Lock()
56+
currentBridge = w
57+
currentBridgeMu.Unlock()
58+
}
59+
60+
func clearCurrentBridge() {
61+
currentBridgeMu.Lock()
62+
currentBridge = nil
63+
currentBridgeMu.Unlock()
64+
}
65+
66+
func closeCurrentBridge() {
67+
currentBridgeMu.Lock()
68+
if currentBridge != nil {
69+
currentBridge.close()
70+
}
71+
currentBridgeMu.Unlock()
72+
}
73+
4574
func main() {
4675
wsURL := os.Getenv("BRIDGE_WS_URL")
4776
if wsURL == "" {
@@ -60,6 +89,9 @@ func main() {
6089
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
6190
go func() {
6291
<-sig
92+
// Gracefully close the bridge WebSocket connection so the server
93+
// gets a clean close frame. This also unblocks ReadMessage().
94+
closeCurrentBridge()
6395
close(quit)
6496
}()
6597

@@ -217,6 +249,16 @@ func (w *wsConn) close() {
217249
_ = w.conn.Close()
218250
}
219251

252+
func (w *wsConn) closeUnderlying() {
253+
w.mu.Lock()
254+
defer w.mu.Unlock()
255+
if w.closed {
256+
return
257+
}
258+
w.closed = true
259+
_ = w.conn.Close()
260+
}
261+
220262
func readCommands(wsc *wsConn, dot string) bool {
221263
defer func() {
222264
printLine("\n", dot, " Connection closed.", ansiReset)
@@ -269,13 +311,133 @@ func readCommands(wsc *wsConn, dot string) bool {
269311
"exit_code": code,
270312
"truncated": truncated,
271313
})
314+
case "file_read":
315+
var msg struct {
316+
ID string `json:"id"`
317+
Path string `json:"path"`
318+
Encoding string `json:"encoding"`
319+
}
320+
_ = json.Unmarshal(data, &msg)
321+
if msg.ID == "" {
322+
continue
323+
}
324+
handleFileRead(wsc, msg.ID, msg.Path)
325+
case "file_write":
326+
var msg struct {
327+
ID string `json:"id"`
328+
Path string `json:"path"`
329+
Data string `json:"data"`
330+
Encoding string `json:"encoding"`
331+
}
332+
_ = json.Unmarshal(data, &msg)
333+
if msg.ID == "" {
334+
continue
335+
}
336+
handleFileWrite(wsc, msg.ID, msg.Path, msg.Data, msg.Encoding)
272337
case "bye":
273338
wsc.close()
274339
return false
275340
}
276341
}
277342
}
278343

344+
func handleFileRead(wsc *wsConn, id, path string) {
345+
printLine(ansiCyan+"📖"+ansiReset, " ", ansiBold, "File read:", ansiReset, " ", path)
346+
data, size, err := readFile(path)
347+
if err != nil {
348+
wsc.sendJSON(map[string]any{
349+
"type": "file_read_result",
350+
"id": id,
351+
"path": path,
352+
"error": err.Error(),
353+
"size": 0,
354+
"data": "",
355+
"encoding": "base64",
356+
})
357+
return
358+
}
359+
wsc.sendJSON(map[string]any{
360+
"type": "file_read_result",
361+
"id": id,
362+
"path": path,
363+
"data": data,
364+
"size": size,
365+
"encoding": "base64",
366+
})
367+
}
368+
369+
func handleFileWrite(wsc *wsConn, id, path, data, encoding string) {
370+
printLine(ansiCyan+"📝"+ansiReset, " ", ansiBold, "File write:", ansiReset, " ", path)
371+
bytesWritten, err := writeFile(path, data, encoding)
372+
if err != nil {
373+
wsc.sendJSON(map[string]any{
374+
"type": "file_write_result",
375+
"id": id,
376+
"path": path,
377+
"error": err.Error(),
378+
"bytes_written": 0,
379+
})
380+
return
381+
}
382+
wsc.sendJSON(map[string]any{
383+
"type": "file_write_result",
384+
"id": id,
385+
"path": path,
386+
"bytes_written": bytesWritten,
387+
})
388+
}
389+
390+
func readFile(path string) (string, int64, error) {
391+
fi, err := os.Stat(path)
392+
if err != nil {
393+
return "", 0, fmt.Errorf("cannot access %s: %w", path, err)
394+
}
395+
if fi.Size() > maxFileSize {
396+
return "", 0, fmt.Errorf("file too large: %d bytes (max %d)", fi.Size(), maxFileSize)
397+
}
398+
399+
f, err := os.Open(path)
400+
if err != nil {
401+
return "", 0, fmt.Errorf("cannot open %s: %w", path, err)
402+
}
403+
defer f.Close()
404+
405+
data, err := io.ReadAll(f)
406+
if err != nil {
407+
return "", 0, fmt.Errorf("cannot read %s: %w", path, err)
408+
}
409+
410+
encoded := base64.StdEncoding.EncodeToString(data)
411+
return encoded, fi.Size(), nil
412+
}
413+
414+
func writeFile(path, data, encoding string) (int, error) {
415+
var raw []byte
416+
switch encoding {
417+
case "base64", "":
418+
var err error
419+
raw, err = base64.StdEncoding.DecodeString(data)
420+
if err != nil {
421+
// Try base64url
422+
raw, err = base64.URLEncoding.DecodeString(data)
423+
if err != nil {
424+
return 0, fmt.Errorf("invalid base64 data: %w", err)
425+
}
426+
}
427+
default:
428+
return 0, fmt.Errorf("unsupported encoding: %s", encoding)
429+
}
430+
431+
if len(raw) > maxFileSize {
432+
return 0, fmt.Errorf("data too large: %d bytes (max %d)", len(raw), maxFileSize)
433+
}
434+
435+
if err := os.WriteFile(path, raw, 0644); err != nil {
436+
return 0, fmt.Errorf("cannot write %s: %w", path, err)
437+
}
438+
return len(raw), nil
439+
}
440+
279441
func dialAndRun(wsURL, code string, quit <-chan struct{}) (string, bool) {
280442
dialer := websocket.Dialer{HandshakeTimeout: 15 * time.Second}
281443
conn, resp, err := dialer.Dial(wsURL, http.Header{})
@@ -294,9 +456,18 @@ func dialAndRun(wsURL, code string, quit <-chan struct{}) (string, bool) {
294456
}
295457

296458
bridge := &wsConn{conn: conn}
459+
setCurrentBridge(bridge)
460+
297461
dot := ansiCyan + "●" + ansiReset
298462
printLine(dot, " ", ansiBold, code, ansiReset, " — Ctrl+C to disconnect")
299463

464+
// Set up heartbeat: expect pong within pongWait
465+
conn.SetReadDeadline(time.Now().Add(pongWait))
466+
conn.SetPongHandler(func(string) error {
467+
conn.SetReadDeadline(time.Now().Add(pongWait))
468+
return nil
469+
})
470+
300471
if !bridge.sendJSON(map[string]any{
301472
"type": "join",
302473
"session": code,
@@ -313,15 +484,18 @@ func dialAndRun(wsURL, code string, quit <-chan struct{}) (string, bool) {
313484
}) {
314485
select {
315486
case <-quit:
487+
clearCurrentBridge()
316488
return "", false
317489
default:
318490
}
319491
printLine(dot, " ", ansiRed, "Join failed, reconnecting...", ansiReset)
320492
bridge.close()
493+
clearCurrentBridge()
321494
return "", true
322495
}
323496

324497
reconnect := readCommands(bridge, dot)
498+
clearCurrentBridge()
325499
return dot, reconnect
326500
}
327501

0 commit comments

Comments
 (0)