// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System; using System.Diagnostics; using System.Net.WebSockets; using System.Text; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace Microsoft.TypeAgent.VisualStudio.Bridge; /// /// Looks up the visualStudio agent's bridge port via the dispatcher's /// discovery channel. /// /// Wire protocol (matches packages/agentServer/protocol/src/protocol.ts): /// client → server: /// { "name": "discovery", /// "message": { "type": "invoke", "callId": N, "name": "lookupPort", /// "args": [{ "agentName": "visualStudio", /// "role": "default" }] } } /// server → client: /// { "name": "discovery", /// "message": { "type": "invokeResult", "callId": N, /// "result": { "port": <int|null> } } } /// /// Returns the discovered port, or null when the agent isn't yet /// registered with the agent-server (transient — caller should retry). /// Throws on transport failure so the outer reconnect loop can apply /// its own retry/backoff. There is intentionally no hardcoded fallback /// port — the migrated TS clients (browser, code, coda) all return /// "undefined" on discovery failure and rely on the reconnect loop; /// dialing a stale well-known port would just connect to nothing. /// internal static class BridgeDiscovery { // Read on every resolve so users can flip behavior without // restarting the IDE between debugging sessions. private const string AgentServerPortEnv = "AGENT_SERVER_PORT"; // Must match AGENT_SERVER_DEFAULT_PORT in agentServer/protocol. private const uint DefaultAgentServerPort = 8999; // Names this client uses to look itself up. Must match the role // registered by visualStudioActionHandler.ts. private const string AgentName = "visualStudio"; private const string Role = "default"; // The dispatcher's discovery channel always lives on the loopback // agent-server. Keep this as a const so the URL only appears in // one place; if/when the host becomes configurable, change here. private const string AgentServerHost = "ws://localhost"; // Sanity cap on the discovery response payload. The protocol only // ever returns a tiny JSON envelope (~100 bytes); anything larger // is treated as a malformed/unexpected response. private const int MaxDiscoveryResponseBytes = 64 * 1024; /// /// Resolve the bridge port via discovery. Returns the discovered /// port, or null when the agent has not yet registered (transient /// — caller should retry on its reconnect loop). /// Throws on transport failure (agent-server unreachable, timeout, /// malformed response) so the caller can log and retry. /// public static async Task ResolveBridgePortAsync(CancellationToken cancellation) { uint agentServerPort = GetAgentServerPort(); uint? discovered = await LookupPortAsync(agentServerPort, cancellation).ConfigureAwait(false); if (discovered is uint p) { Debug.WriteLine($"[TypeAgent] Discovery resolved bridge port {p}"); } else { Debug.WriteLine($"[TypeAgent] Discovery returned null for ({AgentName}, {Role}); agent not yet registered"); } return discovered; } private static uint GetAgentServerPort() { string? raw = Environment.GetEnvironmentVariable(AgentServerPortEnv); if (uint.TryParse(raw, out uint p) && p > 0 && p <= 65535) { return p; } return DefaultAgentServerPort; } private static async Task LookupPortAsync(uint agentServerPort, CancellationToken cancellation) { var uri = new Uri($"{AgentServerHost}:{agentServerPort}/"); using var ws = new ClientWebSocket(); // Cap the discovery call so a hung agent-server doesn't stall // the whole reconnect loop. The outer AgentBridgeClient loop // already retries on a separate cadence. using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellation, timeout.Token); await ws.ConnectAsync(uri, linked.Token).ConfigureAwait(false); // callId is arbitrary — the server echoes it back verbatim, // and we only have one outstanding request per socket. const int callId = 1; var request = new JObject { ["name"] = "discovery", ["message"] = new JObject { ["type"] = "invoke", ["callId"] = callId, ["name"] = "lookupPort", ["args"] = new JArray { new JObject { ["agentName"] = AgentName, ["role"] = Role, }, }, }, }; byte[] requestBytes = Encoding.UTF8.GetBytes(request.ToString(Formatting.None)); await ws.SendAsync( new ArraySegment(requestBytes), WebSocketMessageType.Text, endOfMessage: true, linked.Token).ConfigureAwait(false); string responseText = await ReceiveFullMessageAsync(ws, linked.Token).ConfigureAwait(false); try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None).ConfigureAwait(false); } catch { // Best-effort close — the response is already in hand. } var root = JObject.Parse(responseText); string? name = root.Value("name"); if (name != "discovery") { return null; } var inner = root["message"] as JObject; if (inner == null) { return null; } string? type = inner.Value("type"); if (type == "invokeError") { throw new InvalidOperationException( inner.Value("error") ?? "Discovery returned invokeError"); } if (type != "invokeResult") { return null; } if (inner.Value("callId") != callId) { return null; } var result = inner["result"] as JObject; if (result == null) { return null; } // `port` is `int|null` on the wire; clamp to the valid port // range and surface anything else as "not registered". int? portValue = result.Value("port"); if (portValue is int pv && pv > 0 && pv <= 65535) { return (uint)pv; } return null; } private static async Task ReceiveFullMessageAsync(ClientWebSocket ws, CancellationToken cancellation) { // 16KB receive chunk; we loop until EndOfMessage so this is a // chunk size, not a hard message cap. The MaxDiscoveryResponseBytes // guard below bounds the total payload to protect against a // misbehaving peer streaming garbage. var buffer = new ArraySegment(new byte[16 * 1024]); var sb = new StringBuilder(); WebSocketReceiveResult result; int totalBytes = 0; do { result = await ws.ReceiveAsync(buffer, cancellation).ConfigureAwait(false); if (result.MessageType == WebSocketMessageType.Close) { throw new InvalidOperationException("Discovery WS closed before response"); } totalBytes += result.Count; if (totalBytes > MaxDiscoveryResponseBytes) { throw new InvalidOperationException( $"Discovery response exceeded {MaxDiscoveryResponseBytes} bytes; aborting"); } sb.Append(Encoding.UTF8.GetString(buffer.Array!, 0, result.Count)); } while (!result.EndOfMessage); return sb.ToString(); } }