From f13e1120736c338ae4869d0c1015395092114ba4 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 2 Aug 2026 18:46:33 +0530 Subject: [PATCH 1/7] fix(lsp): dedupe Windows paths and fix server restart race - Normalize the drive letter of server-returned file:// URIs to uppercase on Windows in serverUriToVfsUri. Servers like tsserver lowercase the drive letter, which mapped the same file to a second case-sensitive VFS path, so jump-to-definition opened a duplicate document. Single choke point covers definitions, references and diagnostics for all registered LSP servers. - An intentional restartLanguageServer stop could be misread as a crash: the stopped process exit event lands after _stopping is reset, bumping _crashCount and scheduling an auto-restart that races the restart already in flight (two concurrent starts orphan one initialize request, which then times out after 120s). Guard with a _restarting flag held across the whole stop+start, plus a pid generation check (node side now reports the exiting process pid in serverExit) to ignore stale exits of replaced processes. --- src-node/lsp-client.js | 2 +- src/languageTools/LSPClient.js | 36 +++++++++++++++++++++++++++------- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src-node/lsp-client.js b/src-node/lsp-client.js index d01506a070..98f396ab8d 100644 --- a/src-node/lsp-client.js +++ b/src-node/lsp-client.js @@ -435,7 +435,7 @@ exports.startServer = async function startServer(params) { rejectPending(new Error(`Server ${serverId} exited with pending request`)); } serverState.pending.clear(); - nodeConnector.triggerPeer('serverExit', { serverId, code, signal, stderr }); + nodeConnector.triggerPeer('serverExit', { serverId, code, signal, stderr, pid: serverProcess.pid }); if (!hasResolved) { hasResolved = true; reject(new Error(`Server ${serverId} exited immediately with code ${code}` + diff --git a/src/languageTools/LSPClient.js b/src/languageTools/LSPClient.js index f9646cee99..d8141183e8 100644 --- a/src/languageTools/LSPClient.js +++ b/src/languageTools/LSPClient.js @@ -131,7 +131,14 @@ define(function (require, exports, module) { /** Convert a server `file://` URI (real OS path) back to a VFS-based `file://` URI. */ function serverUriToVfsUri(serverUri) { - const platformPath = PathConverters.uriToPath(serverUri); + let platformPath = PathConverters.uriToPath(serverUri); + // Windows language servers (e.g. tsserver) lowercase the drive letter in URIs + // ("file:///c%3A/..."), but Phoenix VFS mounts use the OS-reported uppercase drive + // ("/tauri/C/..."). The VFS is case-sensitive, so an un-normalized drive letter maps the + // same file to a second path - jump-to-definition would open a duplicate document. + if (brackets.platform === "win" && /^[a-z]:/.test(platformPath)) { + platformPath = platformPath.charAt(0).toUpperCase() + platformPath.substr(1); + } return PathConverters.pathToUri(_toVirtualPath(platformPath)); } @@ -239,12 +246,19 @@ define(function (require, exports, module) { if (!client) { return; } + if (data.pid && client._pid && data.pid !== client._pid) { + // Stale exit from a previous process generation - a restart has already spawned the + // replacement, so this must not clear its state or read as a crash of the new process. + return; + } client.capabilities = null; DocumentSync.clearServer(client); - if (client._stopping || _isDisabledByPref(client.serverId)) { - // Intentional stop/restart - do not auto-restart here. The pref check also covers - // the pref-off stop: its exit event can land after stopServerProcess resolved (and - // reset _stopping), which would otherwise read as a crash and bump _crashCount. + if (client._stopping || client._restarting || _isDisabledByPref(client.serverId)) { + // Intentional stop/restart - do not auto-restart here. The _restarting/pref checks + // also cover the stop's exit event landing after stopServerProcess resolved (and + // reset _stopping), which would otherwise read as a crash, bump _crashCount, and + // schedule an auto-restart that races the restart already in flight (two concurrent + // starts orphan one initialize request, which then times out). return; } // Unexpected crash - log it loudly (with the server's stderr) so failures are never @@ -799,7 +813,7 @@ define(function (require, exports, module) { client.rootUri = rootUri; client.rootName = rootName; - await conn.execPeer("startServer", { + const startResult = await conn.execPeer("startServer", { serverId: client.serverId, command: config.command, args: config.args || ["--stdio"], @@ -807,6 +821,9 @@ define(function (require, exports, module) { workspaceConfiguration: config.workspaceConfiguration, suppressStderrPattern: config.suppressStderrPattern }); + // Process-generation marker: lets _onServerExit tell a stale exit event (a previous + // process, delivered after its replacement already spawned) from a crash of this one. + client._pid = (startResult && startResult.pid) || null; const initResult = await conn.execPeer("sendRequest", { serverId: client.serverId, @@ -1118,8 +1135,11 @@ define(function (require, exports, module) { if (!client || _isDisabledByPref(serverId)) { return; } - await stopServerProcess(client); + // Held for the whole stop+start so the stopped process's exit event - which can land any + // time in between - is never mistaken for a crash (see _onServerExit). + client._restarting = true; try { + await stopServerProcess(client); await _startAndInit(client); _announceServerStarted(client); DocumentSync.openSupportedDocuments(client); @@ -1134,6 +1154,8 @@ define(function (require, exports, module) { Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "srv", "RstErr." + client._metricLabel); window.logger.reportErrorOnce("lspStart." + serverId, err, "[LSP] restart failed: " + serverId); + } finally { + client._restarting = false; } } From a27a586ee50282bd5823b73371c664529ba7f23c Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 2 Aug 2026 18:47:23 +0530 Subject: [PATCH 2/7] fix(jsutils): init preferences on demand in ScopeManager.filterText filterText is called directly by JavaScriptRefactoring highlight references on cursorActivity, which can run before any Tern init or projectOpen has populated preferences, throwing a null getMaxFileSize TypeError on every cursor move over a JS identifier. --- src/JSUtils/ScopeManager.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/JSUtils/ScopeManager.js b/src/JSUtils/ScopeManager.js index b74267e946..ab6944cac1 100644 --- a/src/JSUtils/ScopeManager.js +++ b/src/JSUtils/ScopeManager.js @@ -355,6 +355,9 @@ define(function (require, exports, module) { * @return {string} the text, or the empty text if the original was too long */ function filterText(text) { + // Callers outside this module (e.g. JavaScriptRefactoring's highlight-references) can hit + // this before any Tern init/projectOpen has populated `preferences` - init it on demand. + ensurePreferences(); var newText = text; if (text.length > preferences.getMaxFileSize()) { newText = ""; From cec082e17aa5a53cb6f81589a6ef9c94e8836e8e Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 2 Aug 2026 18:47:58 +0530 Subject: [PATCH 3/7] chore: raise projectOpen listener leak threshold to 30 --- src/project/ProjectManager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/project/ProjectManager.js b/src/project/ProjectManager.js index 9f9a60b125..45ad1a1a30 100644 --- a/src/project/ProjectManager.js +++ b/src/project/ProjectManager.js @@ -160,7 +160,7 @@ define(function (require, exports, module) { const EVENT_PROJECT_CHANGED_OR_RENAMED_PATH = "projectChangedPath"; - EventDispatcher.setLeakThresholdForEvent(EVENT_PROJECT_OPEN, 25); + EventDispatcher.setLeakThresholdForEvent(EVENT_PROJECT_OPEN, 30); const CLIPBOARD_SYNC_KEY = "phoenix.clipboard"; From 6f2d91903902442d7bc0a03abbc45567abd56f0c Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 2 Aug 2026 19:08:14 +0530 Subject: [PATCH 4/7] fix(ai): detect 401 OAuth-expired errors and offer re-login action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK now surfaces auth failures as 'Claude Code returned an error result: Failed to authenticate. API Error: 401 OAuth access token has expired...' which the isAuthError regex missed ('oauth token' does not match 'OAuth access token'), so the panel showed a raw error instead of the login-in-terminal action. Match \b401\b (phrasing-proof), plus 'oauth[\w ]*token' and 're-?authenticate'. 403 stays excluded — it means forbidden, not re-login. --- src-node/claude-code-agent.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index da39742542..f3f5394037 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -2139,8 +2139,14 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // error. Custom API-key providers are excluded — their fix is the // settings-dialog hint appended above. const usingApiKey = !!(envOverrides && envOverrides.ANTHROPIC_AUTH_TOKEN); + // `\b401\b` is the load-bearing match: error phrasing keeps changing + // across CLI/SDK versions ("Failed to authenticate", "OAuth access + // token has expired", "token revoked"...) but the status code stays + // 401. Phrase alternatives remain for exit-code failures where the + // CLI prints a /login hint without any status code. 403 is + // deliberately excluded — it means "forbidden", not "re-login". const isAuthError = !usingApiKey && - /run \/login|invalid api key|not logged in|oauth token|revoke|authentication[_ ]?error/i + /run \/login|invalid api key|not logged in|oauth[\w ]*token|\b401\b|re-?authenticate|revoke|authentication[_ ]?error/i .test(detailedError); nodeConnector.triggerPeer("aiError", { From c9ddfe5039ffe40659f1ae3eb8440729ce56c381 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 2 Aug 2026 19:08:49 +0530 Subject: [PATCH 5/7] feat(ai): add model descriptions for first-launch model picker fallback Before the SDK's supportedModels() list arrives (first ever chat), the model dropdown fell back to a bare list with no Fable entry and no descriptions. Add localized description strings for the static fallback, including an access caveat for Fable and a generic Default subtext shown until the resolved default model is known. --- src/nls/root/strings.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 255989b398..e30af57fc3 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2664,6 +2664,11 @@ define({ "AI_CHAT_AUTH_ERROR_HINT": "Type /login in the terminal that opens, then send your message again.", "AI_CHAT_MODEL_DEFAULT": "Default", "AI_CHAT_MODEL_DEFAULT_DESC_CURRENT": "Currently {0}", + "AI_CHAT_MODEL_DEFAULT_DESC": "Recommended — uses your Claude Code model setting", + "AI_CHAT_MODEL_DESC_FABLE": "Most intelligent model — only if your plan has Fable access", + "AI_CHAT_MODEL_DESC_OPUS": "Powerful model for complex tasks", + "AI_CHAT_MODEL_DESC_SONNET": "Balanced speed and capability for everyday coding", + "AI_CHAT_MODEL_DESC_HAIKU": "Fastest model for quick, simple tasks", "AI_CHAT_MODEL_SELECT_TITLE": "Choose the AI model for this chat", "AI_CHAT_MODEL_SWITCHED_NOTICE": "Switched to {0}. Applies from your next message; the first response may take a moment longer while the cache rebuilds.", "AI_CHAT_INPUT_HINT": "Press {0} to send · {1} for new line", From e3cb549bce432a303773dccee4c38206e3037dae Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 2 Aug 2026 19:12:32 +0530 Subject: [PATCH 6/7] chore: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index f58c08b100..c20e82a590 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "4aa07ee038916c8765baa9f87959759a657b9221" + "commitID": "e949adfa964caa7ba6e294be475124452b97110c" } } From 54437b568e8fffad9e3df9993a3745f9d996d8d2 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 2 Aug 2026 20:12:26 +0530 Subject: [PATCH 7/7] fix(lsp): guard server registry against stale exit events from replaced processes During a fast restart the old process's exit event can land after the replacement server was already registered. The exit/error handlers deleted the registry entry unconditionally, removing the NEW server's entry - its initialize response was then dropped in handleMessage (servers.get finds nothing), timing out after 120s, and every later request failed 'not running' while the replacement process leaked, alive but unreachable. Only delete the entry when the exiting process is still the registered generation. Node-side counterpart of the browser-side pid-generation guard from f13e11207. --- src-node/lsp-client.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src-node/lsp-client.js b/src-node/lsp-client.js index 98f396ab8d..16307cd6dd 100644 --- a/src-node/lsp-client.js +++ b/src-node/lsp-client.js @@ -424,7 +424,15 @@ exports.startServer = async function startServer(params) { }); serverProcess.on('exit', (code, signal) => { - servers.delete(serverId); + // Only clear the registry when this process is still the registered generation. + // During a fast restart the old process's exit event can land after the + // replacement was already registered - an unconditional delete would remove the + // NEW server's entry, so its initialize response gets dropped in handleMessage + // (servers.get finds nothing) and every later request fails "not running" while + // the replacement process leaks, still alive but unreachable. + if (servers.get(serverId) === serverState) { + servers.delete(serverId); + } const stderr = serverState.stderrTail.join(''); if (code) { console.error(`[lsp-client][${serverId}] exited code=${code} signal=${signal || 'none'}`); @@ -445,7 +453,9 @@ exports.startServer = async function startServer(params) { serverProcess.on('error', (err) => { console.error(`[lsp-client][${serverId}] spawn error:`, err.message); - servers.delete(serverId); + if (servers.get(serverId) === serverState) { + servers.delete(serverId); + } nodeConnector.triggerPeer('serverError', { serverId, error: err.message }); if (!hasResolved) { hasResolved = true;