5 files changed,
+232,
-55
+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.
+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 }
+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
+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 }
+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