fixes for race bugs
5 files changed,  +232, -55
M sync/conn.go
+40, -11
  1@@ -85,7 +85,7 @@ func NewSyncConn(doc DocInterface, opts Options) *SyncConn {
  2 }
  3 
  4 func (s *SyncConn) connect(ctx context.Context) error {
  5-	s.status = StatusConnecting
  6+	s.setStatus(StatusConnecting)
  7 
  8 	c, _, err := websocket.Dial(ctx, s.opts.Endpoint, &websocket.DialOptions{
  9 		HTTPHeader: map[string][]string{
 10@@ -97,7 +97,7 @@ func (s *SyncConn) connect(ctx context.Context) error {
 11 	}
 12 
 13 	s.conn = c
 14-	s.status = StatusConnected
 15+	s.setStatus(StatusConnected)
 16 	s.retries = 0
 17 
 18 	// Trigger OnConnect callback
 19@@ -291,7 +291,9 @@ func (s *SyncConn) readLoop(ctx context.Context) {
 20 		if err != nil {
 21 			// Check if this was a server-initiated disconnect (no context cancellation)
 22 			if ctx.Err() == nil {
 23+				s.statsMu.Lock()
 24 				s.disconnectReason = DisconnectReasonYSweetInitiated
 25+				s.statsMu.Unlock()
 26 			}
 27 			s.handleDisconnect(ctx)
 28 			return
 29@@ -313,24 +315,33 @@ func (s *SyncConn) readLoop(ctx context.Context) {
 30 }
 31 
 32 func (s *SyncConn) handleDisconnect(ctx context.Context) {
 33-	if s.status == StatusDisconnecting {
 34+	if s.getStatus() == StatusDisconnecting {
 35 		return
 36 	}
 37 
 38 	// Get current reason and trigger OnDisconnect callback
 39+	s.statsMu.RLock()
 40 	reason := s.disconnectReason
 41-	if s.onDisconnect != nil {
 42-		s.onDisconnect(reason)
 43+	s.statsMu.RUnlock()
 44+
 45+	s.statsMu.RLock()
 46+	onDisconnect := s.onDisconnect
 47+	s.statsMu.RUnlock()
 48+
 49+	if onDisconnect != nil {
 50+		onDisconnect(reason)
 51 	}
 52 
 53-	s.status = StatusDisconnected
 54+	s.setStatus(StatusDisconnected)
 55 	if s.conn != nil {
 56 		s.conn.Close(websocket.StatusNormalClosure, "")
 57 		s.conn = nil
 58 	}
 59 
 60 	// Reset disconnect reason for next connection (default to YSweetInitiated for reconnects)
 61+	s.statsMu.Lock()
 62 	s.disconnectReason = DisconnectReasonYSweetInitiated
 63+	s.statsMu.Unlock()
 64 
 65 	backoff := initialBackoff
 66 	for {
 67@@ -365,13 +376,13 @@ func (s *SyncConn) handleDisconnect(ctx context.Context) {
 68 // It sends any pending updates before closing the WebSocket.
 69 func (s *SyncConn) Close() error {
 70 	s.closeOnce.Do(func() {
 71-		s.status = StatusDisconnecting
 72+		s.setStatus(StatusDisconnecting)
 73 		close(s.done)
 74 
 75 		if s.conn != nil {
 76 			s.closeErr = s.conn.Close(websocket.StatusNormalClosure, "")
 77 		}
 78-		s.status = StatusDisconnected
 79+		s.setStatus(StatusDisconnected)
 80 	})
 81 	return s.closeErr
 82 }
 83@@ -380,7 +391,7 @@ func (s *SyncConn) Close() error {
 84 // Use this before destroying the document to ensure pending changes are sent.
 85 func (s *SyncConn) SendPendingAndClose() error {
 86 	s.closeOnce.Do(func() {
 87-		s.status = StatusDisconnecting
 88+		s.setStatus(StatusDisconnecting)
 89 
 90 		// Send final pending update if connected
 91 		if s.conn != nil {
 92@@ -410,7 +421,7 @@ func (s *SyncConn) SendPendingAndClose() error {
 93 		}
 94 
 95 		close(s.done)
 96-		s.status = StatusDisconnected
 97+		s.setStatus(StatusDisconnected)
 98 	})
 99 	return s.closeErr
100 }
101@@ -439,7 +450,9 @@ func (s *SyncConn) SetDoc(doc DocInterface) {
102 
103 // SetOnUpdate sets the callback for handling updates from the server.
104 func (s *SyncConn) SetOnUpdate(fn func(DocInterface) error) {
105+	s.statsMu.Lock()
106 	s.onUpdate = fn
107+	s.statsMu.Unlock()
108 }
109 
110 // GetConn returns the underlying WebSocket connection.
111@@ -466,6 +479,20 @@ func (s *SyncConn) GetStats() Stats {
112 	}
113 }
114 
115+// setStatus safely sets the connection status
116+func (s *SyncConn) setStatus(status Status) {
117+	s.statsMu.Lock()
118+	s.status = status
119+	s.statsMu.Unlock()
120+}
121+
122+// getStatus safely gets the connection status
123+func (s *SyncConn) getStatus() Status {
124+	s.statsMu.RLock()
125+	defer s.statsMu.RUnlock()
126+	return s.status
127+}
128+
129 // SetPendingUpdate marks whether there are local changes waiting to sync.
130 func (s *SyncConn) SetPendingUpdate(pending bool) {
131 	s.statsMu.Lock()
132@@ -477,7 +504,7 @@ func (s *SyncConn) SetPendingUpdate(pending bool) {
133 // It sends any pending local changes synchronously.
134 // The context can be used to set a timeout.
135 func (s *SyncConn) Flush(ctx context.Context) error {
136-	if s.status != StatusConnected || s.conn == nil {
137+	if s.getStatus() != StatusConnected || s.conn == nil {
138 		return fmt.Errorf("not connected")
139 	}
140 
141@@ -523,7 +550,9 @@ func (s *SyncConn) Flush(ctx context.Context) error {
142 // SetDisconnectReason sets the reason for the next disconnect.
143 // This is used by the awareness client to indicate DocumentIdle.
144 func (s *SyncConn) SetDisconnectReason(reason DisconnectReason) {
145+	s.statsMu.Lock()
146 	s.disconnectReason = reason
147+	s.statsMu.Unlock()
148 }
149 
150 // parseAwarenessPeerCount extracts the number of peers from an awareness message.
M sync/types.go
+67, -21
  1@@ -91,6 +91,7 @@ type SyncClient struct {
  2 	closeOnce    sync.Once
  3 	done         chan struct{}
  4 	disconnect   func()
  5+	mu           sync.RWMutex // protects status and syncConn
  6 
  7 	// Internal sync connection for actual WebSocket handling
  8 	syncConn *SyncConn
  9@@ -103,6 +104,8 @@ func (c *SyncClient) Done() <-chan struct{} {
 10 
 11 // Status returns the current connection state of the client.
 12 func (c *SyncClient) Status() Status {
 13+	c.mu.RLock()
 14+	defer c.mu.RUnlock()
 15 	return c.status
 16 }
 17 
 18@@ -152,8 +155,11 @@ func NewSyncClient(doc DocInterface, opts ...Option) *SyncClient {
 19 			client.syncConn.SetDisconnectReason(DisconnectReasonDocumentIdle)
 20 			// Trigger the disconnect callback directly with DocumentIdle reason
 21 			// Note: onDisconnect is the field, not opts.OnDisconnect
 22-			if client.syncConn.onDisconnect != nil {
 23-				client.syncConn.onDisconnect(DisconnectReasonDocumentIdle)
 24+			client.syncConn.statsMu.RLock()
 25+			onDisconnect := client.syncConn.onDisconnect
 26+			client.syncConn.statsMu.RUnlock()
 27+			if onDisconnect != nil {
 28+				onDisconnect(DisconnectReasonDocumentIdle)
 29 			}
 30 			client.syncConn.Close()
 31 		}
 32@@ -252,12 +258,17 @@ func WithOnDisconnect(fn func(DisconnectReason)) Option {
 33 // SendUpdate sends a document update to the server.
 34 // It returns nil if not connected, so callers don't need to check status.
 35 func (c *SyncClient) SendUpdate(update UpdateData) error {
 36-	if c.status != StatusConnected || c.syncConn == nil {
 37+	c.mu.RLock()
 38+	status := c.status
 39+	syncConn := c.syncConn
 40+	c.mu.RUnlock()
 41+
 42+	if status != StatusConnected || syncConn == nil {
 43 		return nil
 44 	}
 45 
 46 	// Access the connection through syncConn
 47-	conn := c.syncConn.GetConn()
 48+	conn := syncConn.GetConn()
 49 	if conn == nil {
 50 		return nil
 51 	}
 52@@ -287,75 +298,109 @@ func (c *SyncClient) SetAuthToken(token string) {
 53 // SetOnUpdate sets the callback function that is called when the document
 54 // receives updates from the server.
 55 func (c *SyncClient) SetOnUpdate(fn func(DocInterface) error) {
 56+	c.mu.Lock()
 57 	c.onUpdate = fn
 58-	if c.syncConn != nil {
 59-		c.syncConn.SetOnUpdate(fn)
 60+	syncConn := c.syncConn
 61+	c.mu.Unlock()
 62+
 63+	if syncConn != nil {
 64+		syncConn.SetOnUpdate(fn)
 65 	}
 66 }
 67 
 68 // Start connects to the y-sweet server and begins synchronization.
 69 func (c *SyncClient) Start(ctx context.Context) error {
 70-	if c.syncConn == nil {
 71+	c.mu.RLock()
 72+	syncConn := c.syncConn
 73+	c.mu.RUnlock()
 74+
 75+	if syncConn == nil {
 76 		return fmt.Errorf("sync connection not initialized")
 77 	}
 78 
 79 	// Start the sync connection
 80-	err := c.syncConn.Start(ctx)
 81+	err := syncConn.Start(ctx)
 82+
 83+	c.mu.Lock()
 84 	if err != nil {
 85 		c.status = StatusDisconnected
 86-		return err
 87+	} else {
 88+		c.status = StatusConnected
 89 	}
 90+	c.mu.Unlock()
 91 
 92-	c.status = StatusConnected
 93-	return nil
 94+	return err
 95 }
 96 
 97 // Close gracefully shuts down the sync connection.
 98 func (c *SyncClient) Close() error {
 99-	if c.syncConn != nil {
100-		return c.syncConn.Close()
101-	}
102+	c.mu.Lock()
103+	syncConn := c.syncConn
104 	c.status = StatusDisconnected
105+	c.mu.Unlock()
106+
107+	if syncConn != nil {
108+		return syncConn.Close()
109+	}
110 	return nil
111 }
112 
113 // SendPendingAndClose sends any pending updates then closes the connection.
114 // This is safe to call from within the OnUpdate callback.
115 func (c *SyncClient) SendPendingAndClose() error {
116-	if c.syncConn == nil {
117+	c.mu.RLock()
118+	syncConn := c.syncConn
119+	c.mu.RUnlock()
120+
121+	if syncConn == nil {
122 		return nil
123 	}
124-	return c.syncConn.SendPendingAndClose()
125+	return syncConn.SendPendingAndClose()
126 }
127 
128 // GetStats returns current connection statistics from the underlying sync connection.
129 func (c *SyncClient) GetStats() Stats {
130-	if c.syncConn == nil {
131+	c.mu.RLock()
132+	syncConn := c.syncConn
133+	status := c.status
134+	c.mu.RUnlock()
135+
136+	if syncConn == nil {
137 		return Stats{
138-			Status: c.status,
139+			Status: status,
140 		}
141 	}
142-	return c.syncConn.GetStats()
143+	return syncConn.GetStats()
144 }
145 
146 // Flush performs a best-effort sync of pending updates.
147 func (c *SyncClient) Flush(ctx context.Context) error {
148-	if c.syncConn == nil {
149+	c.mu.RLock()
150+	syncConn := c.syncConn
151+	c.mu.RUnlock()
152+
153+	if syncConn == nil {
154 		return fmt.Errorf("not connected")
155 	}
156-	return c.syncConn.Flush(ctx)
157+	return syncConn.Flush(ctx)
158 }
159 
160 // GetSyncConn returns the underlying SyncConn for advanced use.
161 // This allows access to lower-level connection methods.
162 func (c *SyncClient) GetSyncConn() *SyncConn {
163+	c.mu.RLock()
164+	defer c.mu.RUnlock()
165 	return c.syncConn
166 }
167 
168 // SetDisconnectCallback sets a callback that is called when a disconnect
169 // occurs with the reason for the disconnect.
170 func (c *SyncClient) SetDisconnectCallback(fn func(DisconnectReason)) {
171+	c.mu.Lock()
172+	defer c.mu.Unlock()
173+
174 	// Wrap the callback to set the reason before calling
175+	c.syncConn.statsMu.Lock()
176 	originalOnDisconnect := c.syncConn.onDisconnect
177 	c.syncConn.onDisconnect = func(reason DisconnectReason) {
178 		if fn != nil {
179@@ -365,4 +410,5 @@ func (c *SyncClient) SetDisconnectCallback(fn func(DisconnectReason)) {
180 			originalOnDisconnect(reason)
181 		}
182 	}
183+	c.syncConn.statsMu.Unlock()
184 }
M sync_client.go
+55, -9
  1@@ -89,6 +89,13 @@ func NewSyncClient(doc *Doc, opts ...SyncOption) (*SyncClient, error) {
  2 // If the context is cancelled, the client will gracefully shutdown,
  3 // flush any pending updates, and close the connection.
  4 func (c *SyncClient) Connect(ctx context.Context) error {
  5+	// Capture callback values first, before acquiring the main lock
  6+	c.statsMu.RLock()
  7+	onUpdate := c.onUpdate
  8+	onConnect := c.onConnect
  9+	onDisconnect := c.onDisconnect
 10+	c.statsMu.RUnlock()
 11+
 12 	c.statsMu.Lock()
 13 	defer c.statsMu.Unlock()
 14 
 15@@ -128,9 +135,10 @@ func (c *SyncClient) Connect(ctx context.Context) error {
 16 	}
 17 
 18 	// Set up internal callbacks that wrap our SyncHookCtx callbacks
 19-	if c.onUpdate != nil {
 20+	// Using captured callback values to avoid race conditions
 21+
 22+	if onUpdate != nil {
 23 		doc := c.doc
 24-		onUpdate := c.onUpdate
 25 		c.inner.SetOnUpdate(func(di ygosync.DocInterface) error {
 26 			stats := c.getStatsUnsafe()
 27 			return onUpdate(context.Background(), doc, stats)
 28@@ -140,7 +148,7 @@ func (c *SyncClient) Connect(ctx context.Context) error {
 29 	// Set up disconnect callback with reason
 30 	disconnectCalled := false
 31 	c.inner.SetDisconnectCallback(func(reason ygosync.DisconnectReason) {
 32-		if !disconnectCalled && c.onDisconnect != nil {
 33+		if !disconnectCalled && onDisconnect != nil {
 34 			disconnectCalled = true
 35 			stats := c.getStatsUnsafe()
 36 			// Convert internal reason to public reason
 37@@ -153,7 +161,7 @@ func (c *SyncClient) Connect(ctx context.Context) error {
 38 			case ygosync.DisconnectReasonDocumentIdle:
 39 				publicReason = DisconnectReasonDocumentIdle
 40 			}
 41-			c.onDisconnect(context.Background(), c.doc, stats, publicReason)
 42+			onDisconnect(context.Background(), c.doc, stats, publicReason)
 43 		}
 44 	})
 45 
 46@@ -168,10 +176,10 @@ func (c *SyncClient) Connect(ctx context.Context) error {
 47 	}
 48 
 49 	// Trigger OnConnect callback
 50-	if c.onConnect != nil {
 51+	if onConnect != nil {
 52 		go func() {
 53 			stats := c.Stats()
 54-			c.onConnect(context.Background(), c.doc, stats)
 55+			onConnect(context.Background(), c.doc, stats)
 56 		}()
 57 	}
 58 
 59@@ -285,8 +293,8 @@ func (c *SyncClient) OnConnect(fn SyncHook) {
 60 // OnConnectCtx sets a context-aware callback for connect events.
 61 func (c *SyncClient) OnConnectCtx(fn SyncHookCtx) {
 62 	c.statsMu.Lock()
 63-	defer c.statsMu.Unlock()
 64 	c.onConnect = fn
 65+	c.statsMu.Unlock()
 66 }
 67 
 68 // OnDisconnect sets a callback that is invoked when the client disconnects.
 69@@ -302,8 +310,32 @@ func (c *SyncClient) OnDisconnect(fn DisconnectHook) {
 70 // OnDisconnectCtx sets a context-aware callback for disconnect events.
 71 func (c *SyncClient) OnDisconnectCtx(fn DisconnectHookCtx) {
 72 	c.statsMu.Lock()
 73-	defer c.statsMu.Unlock()
 74 	c.onDisconnect = fn
 75+	c.statsMu.Unlock()
 76+
 77+	// Also propagate to inner syncConn if already connected
 78+	if c.inner != nil {
 79+		c.inner.SetDisconnectCallback(func(reason ygosync.DisconnectReason) {
 80+			c.statsMu.RLock()
 81+			onDisconnect := c.onDisconnect
 82+			c.statsMu.RUnlock()
 83+
 84+			if onDisconnect != nil {
 85+				stats := c.getStatsUnsafe()
 86+				// Convert internal reason to public reason
 87+				var publicReason DisconnectReason
 88+				switch reason {
 89+				case ygosync.DisconnectReasonClientInitiated:
 90+					publicReason = DisconnectReasonClientInitiated
 91+				case ygosync.DisconnectReasonYSweetInitiated:
 92+					publicReason = DisconnectReasonYSweetInitiated
 93+				case ygosync.DisconnectReasonDocumentIdle:
 94+					publicReason = DisconnectReasonDocumentIdle
 95+				}
 96+				onDisconnect(context.Background(), c.doc, stats, publicReason)
 97+			}
 98+		})
 99+	}
100 }
101 
102 // OnUpdate sets a callback that is invoked when the document receives updates from peers.
103@@ -318,8 +350,22 @@ func (c *SyncClient) OnUpdate(fn SyncHook) {
104 // OnUpdateCtx sets a context-aware callback for update events.
105 func (c *SyncClient) OnUpdateCtx(fn SyncHookCtx) {
106 	c.statsMu.Lock()
107-	defer c.statsMu.Unlock()
108 	c.onUpdate = fn
109+	c.statsMu.Unlock()
110+
111+	// Also propagate to inner syncConn if already connected
112+	if c.inner != nil {
113+		c.inner.SetOnUpdate(func(di ygosync.DocInterface) error {
114+			c.statsMu.RLock()
115+			onUpdate := c.onUpdate
116+			c.statsMu.RUnlock()
117+
118+			if onUpdate != nil {
119+				return onUpdate(context.Background(), c.doc, c.getStatsUnsafe())
120+			}
121+			return nil
122+		})
123+	}
124 }
125 
126 // syncStatusFromInternal converts internal ygosync.Status to public SyncStatus
M sync_client_integration_test.go
+34, -6
 1@@ -2,6 +2,8 @@ package ygo_test
 2 
 3 import (
 4 	"context"
 5+	"fmt"
 6+	"net"
 7 	"os"
 8 	"os/exec"
 9 	"syscall"
10@@ -18,14 +20,34 @@ func skipIfNoIntegrationTests(t *testing.T) {
11 	}
12 }
13 
14+// getFreePort returns a random available port
15+func getFreePort() (int, error) {
16+	addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
17+	if err != nil {
18+		return 0, err
19+	}
20+	l, err := net.ListenTCP("tcp", addr)
21+	if err != nil {
22+		return 0, err
23+	}
24+	defer l.Close()
25+	return l.Addr().(*net.TCPAddr).Port, nil
26+}
27+
28 func TestSyncClientDisconnectReasonYSweetInitiated(t *testing.T) {
29 	skipIfNoIntegrationTests(t)
30 
31-	// Start y-sweet server
32+	// Get a random available port
33+	port, err := getFreePort()
34+	if err != nil {
35+		t.Fatalf("failed to get free port: %v", err)
36+	}
37+
38+	// Start y-sweet server on random port
39 	ctx, cancel := context.WithCancel(context.Background())
40 	defer cancel()
41 
42-	cmd := exec.CommandContext(ctx, "pnpx", "y-sweet@latest", "serve")
43+	cmd := exec.CommandContext(ctx, "pnpx", "y-sweet@latest", "serve", fmt.Sprintf("--port=%d", port))
44 	cmd.SysProcAttr = &syscall.SysProcAttr{
45 		Setpgid: true,
46 	}
47@@ -38,7 +60,7 @@ func TestSyncClientDisconnectReasonYSweetInitiated(t *testing.T) {
48 	time.Sleep(2 * time.Second)
49 
50 	// Create client and connect
51-	client, err := ysweet.NewClient("http://127.0.0.1:8080")
52+	client, err := ysweet.NewClient(fmt.Sprintf("http://127.0.0.1:%d", port))
53 	if err != nil {
54 		t.Fatalf("failed to create y-sweet client: %v", err)
55 	}
56@@ -96,11 +118,17 @@ func TestSyncClientDisconnectReasonYSweetInitiated(t *testing.T) {
57 func TestSyncClientDisconnectReasonDocumentIdle(t *testing.T) {
58 	skipIfNoIntegrationTests(t)
59 
60-	// Start y-sweet server
61+	// Get a random available port
62+	port, err := getFreePort()
63+	if err != nil {
64+		t.Fatalf("failed to get free port: %v", err)
65+	}
66+
67+	// Start y-sweet server on random port
68 	ctx, cancel := context.WithCancel(context.Background())
69 	defer cancel()
70 
71-	cmd := exec.CommandContext(ctx, "pnpx", "y-sweet@latest", "serve")
72+	cmd := exec.CommandContext(ctx, "pnpx", "y-sweet@latest", "serve", fmt.Sprintf("--port=%d", port))
73 	cmd.SysProcAttr = &syscall.SysProcAttr{
74 		Setpgid: true,
75 	}
76@@ -113,7 +141,7 @@ func TestSyncClientDisconnectReasonDocumentIdle(t *testing.T) {
77 	time.Sleep(2 * time.Second)
78 
79 	// Create client and connect
80-	client, err := ysweet.NewClient("http://127.0.0.1:8080")
81+	client, err := ysweet.NewClient(fmt.Sprintf("http://127.0.0.1:%d", port))
82 	if err != nil {
83 		t.Fatalf("failed to create y-sweet client: %v", err)
84 	}
M ysweet/proxy_integration_test.go
+36, -8
  1@@ -2,6 +2,8 @@ package ysweet_test
  2 
  3 import (
  4 	"context"
  5+	"fmt"
  6+	"net"
  7 	"net/http"
  8 	"net/http/httptest"
  9 	"os"
 10@@ -22,6 +24,20 @@ func skipIfNoIntegrationTests(t *testing.T) {
 11 	}
 12 }
 13 
 14+// getFreePort returns a random available port
 15+func getFreePort() (int, error) {
 16+	addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
 17+	if err != nil {
 18+		return 0, err
 19+	}
 20+	l, err := net.ListenTCP("tcp", addr)
 21+	if err != nil {
 22+		return 0, err
 23+	}
 24+	defer l.Close()
 25+	return l.Addr().(*net.TCPAddr).Port, nil
 26+}
 27+
 28 // TestProxyHandler_Integration tests the proxy handler with a live y-sweet server.
 29 // It starts a y-sweet server, creates a proxy in front of it, and verifies that:
 30 // 1. Two clients can connect through the proxy to the same document
 31@@ -30,11 +46,17 @@ func skipIfNoIntegrationTests(t *testing.T) {
 32 func TestProxyHandler_Integration(t *testing.T) {
 33 	skipIfNoIntegrationTests(t)
 34 
 35-	// Start y-sweet server
 36+	// Get a random available port
 37+	port, err := getFreePort()
 38+	if err != nil {
 39+		t.Fatalf("failed to get free port: %v", err)
 40+	}
 41+
 42+	// Start y-sweet server on random port
 43 	ctx, cancel := context.WithCancel(context.Background())
 44 	defer cancel()
 45 
 46-	cmd := exec.CommandContext(ctx, "pnpx", "y-sweet@latest", "serve")
 47+	cmd := exec.CommandContext(ctx, "pnpx", "y-sweet@latest", "serve", fmt.Sprintf("--port=%d", port))
 48 	cmd.SysProcAttr = &syscall.SysProcAttr{
 49 		Setpgid: true,
 50 	}
 51@@ -47,7 +69,7 @@ func TestProxyHandler_Integration(t *testing.T) {
 52 	time.Sleep(2 * time.Second)
 53 
 54 	// Create y-sweet client to create a document
 55-	client, err := ysweet.NewClient("http://127.0.0.1:8080")
 56+	client, err := ysweet.NewClient(fmt.Sprintf("http://127.0.0.1:%d", port))
 57 	if err != nil {
 58 		t.Fatalf("failed to create y-sweet client: %v", err)
 59 	}
 60@@ -63,7 +85,7 @@ func TestProxyHandler_Integration(t *testing.T) {
 61 	upgradeHookCalls := make(chan string, 10)
 62 
 63 	// Create proxy handler in front of y-sweet server
 64-	proxyHandler := ysweet.ProxyHandler("ws://127.0.0.1:8080",
 65+	proxyHandler := ysweet.ProxyHandler(fmt.Sprintf("ws://127.0.0.1:%d", port),
 66 		ysweet.WithOnConnect(func(ctx context.Context, docID string, w http.ResponseWriter, r *http.Request) error {
 67 			connectHookCalls <- docID
 68 			return nil
 69@@ -279,11 +301,17 @@ func TestProxyHandler_Integration(t *testing.T) {
 70 func TestProxyHandler_IntegrationWithCustomURLPattern(t *testing.T) {
 71 	skipIfNoIntegrationTests(t)
 72 
 73-	// Start y-sweet server
 74+	// Get a random available port
 75+	port, err := getFreePort()
 76+	if err != nil {
 77+		t.Fatalf("failed to get free port: %v", err)
 78+	}
 79+
 80+	// Start y-sweet server on random port
 81 	ctx, cancel := context.WithCancel(context.Background())
 82 	defer cancel()
 83 
 84-	cmd := exec.CommandContext(ctx, "pnpx", "y-sweet@latest", "serve")
 85+	cmd := exec.CommandContext(ctx, "pnpx", "y-sweet@latest", "serve", fmt.Sprintf("--port=%d", port))
 86 	cmd.SysProcAttr = &syscall.SysProcAttr{
 87 		Setpgid: true,
 88 	}
 89@@ -296,7 +324,7 @@ func TestProxyHandler_IntegrationWithCustomURLPattern(t *testing.T) {
 90 	time.Sleep(2 * time.Second)
 91 
 92 	// Create y-sweet client to create a document
 93-	client, err := ysweet.NewClient("http://127.0.0.1:8080")
 94+	client, err := ysweet.NewClient(fmt.Sprintf("http://127.0.0.1:%d", port))
 95 	if err != nil {
 96 		t.Fatalf("failed to create y-sweet client: %v", err)
 97 	}
 98@@ -310,7 +338,7 @@ func TestProxyHandler_IntegrationWithCustomURLPattern(t *testing.T) {
 99 	connectHookCalls := make(chan string, 10)
100 
101 	// Create proxy handler with custom URL pattern: /api/doc/{docID}
102-	proxyHandler := ysweet.ProxyHandler("ws://127.0.0.1:8080",
103+	proxyHandler := ysweet.ProxyHandler(fmt.Sprintf("ws://127.0.0.1:%d", port),
104 		ysweet.WithDocIDFunc(func(r *http.Request) string {
105 			// Extract docID from /api/doc/{docID}
106 			path := r.URL.Path