From 4ded1916e6afbd2f14d246f696db05660dca9f39 Mon Sep 17 00:00:00 2001 From: abose Date: Tue, 23 Dec 2025 10:03:38 +0530 Subject: [PATCH 1/3] feat: infra to register and trigger lp function from phoenixLiveDevProtocol.triggerLPFn --- .../BrowserScripts/LiveDevProtocolRemote.js | 39 ++++++++-- .../BrowserScripts/RemoteFunctions.js | 7 ++ .../protocol/LiveDevProtocol.js | 71 ++++++++++++++++++- 3 files changed, 110 insertions(+), 7 deletions(-) diff --git a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js index f026291e1..dc42de724 100644 --- a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js +++ b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js @@ -341,11 +341,9 @@ * @param {string} msgStr The protocol message as stringified JSON. */ message: function (msgStr) { - var msg; - try { - msg = JSON.parse(msgStr); - } catch (e) { - console.error("[Brackets LiveDev] Malformed message received: ", msgStr); + const msg = JSON.parse(msgStr); + if(msg && typeof msg === "object" && msg.method === "PhoenixComm.execLPFn") { + _onLPFnTrigger(msg.fnName, msg.params); return; } // delegates handling/routing to MessageBroker. @@ -363,6 +361,37 @@ ProtocolManager.setProtocolHandler(ProtocolHandler); + const registeredPhoenixCommFns = {}; + + // never rejects + function _onLPFnTrigger(fnName, paramObj) { + const lpFn = registeredPhoenixCommFns[fnName]; + if(!lpFn) { + console.error(`PhoenixComm: No such LP function ${fnName}`); + } + try { + const response = lpFn(paramObj); + if(response instanceof Promise) { + response.catch(err => { + console.error(`PhoenixComm: Error executing LP function ${fnName}`, err); + }); + } + } catch (e) { + console.error(`PhoenixComm: Error executing LP function ${fnName}`, e); + } + } + + const PhoenixComm = { + registerLpFn: function (fnName, fn) { + if(registeredPhoenixCommFns[fnName]){ + throw new Error(`Function "${fnName}" already registered with PhoenixComm`); + } + registeredPhoenixCommFns[fnName] = fn; + } + }; + + global._Brackets_LiveDev_PhoenixComm = PhoenixComm; + window.addEventListener('load', function () { ProtocolManager.enable(); }); diff --git a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js index f170f507f..f00a295e0 100644 --- a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js +++ b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js @@ -15,6 +15,13 @@ function RemoteFunctions(config = {}) { HIGHLIGHT_CLASSNAME: "__brackets-ld-highlight" // CSS class name used for highlighting elements in live preview }; + // this is for bidirectional communication between phoenix and live preview + const PhoenixComm = window._Brackets_LiveDev_PhoenixComm; + PhoenixComm.registerLpFn("PH_Hello", function(param) { + // this is just a test function here to check if live preview. fn call is working correctly. + console.log("Hello World", param); + }); + const SHARED_STATE = { __description: "Use this to keep shared state for Live Preview Edit instead of window.*" }; diff --git a/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js b/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js index cfa52533f..c81f47c30 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js +++ b/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js @@ -61,6 +61,8 @@ define(function (require, exports, module) { const EVENT_LIVE_PREVIEW_CLICKED = "livePreviewClicked", EVENT_LIVE_PREVIEW_RELOAD = "livePreviewReload"; + const MAX_PENDING_LP_CALLS_1000 = 1000; + /** * @private * Active connections. @@ -205,7 +207,7 @@ define(function (require, exports, module) { } const processedMessageIDs = new Phoenix.libs.LRUCache({ - max: 1000 + max: MAX_PENDING_LP_CALLS_1000 // we dont need to set a ttl here as message ids are unique throughout lifetime. And old ids will // start getting evited from the cache. the message ids are only an issue within a fraction of a seconds when // a series of messages are sent in quick succession. Eg. user click on a div and there are 3 tabs and due to @@ -294,7 +296,7 @@ define(function (require, exports, module) { // broadcast if there are no specific clients clients = clients || getConnectionIds(); msg.id = id; - _responseDeferreds[id] = result; + _responseDeferreds[id] = result; // todo responses deffered if size larger than 100k enttries raise metric and warn in console once every 10 seconds long only _transport.send(clients, JSON.stringify(msg)); return result.promise(); } @@ -484,6 +486,68 @@ define(function (require, exports, module) { ); } + const registeredFunctions = {}; + function registerPhoenixFn(fnName, fn) { + if(registeredFunctions[fnName]){ + throw new Error(`Function "${fnName}" already registered with LPComm`); + } + registeredFunctions[fnName] = fn; + } + + /** + * Triggers a named API function in the Live Preview for one or more clients + * in a **fire-and-forget** manner. + * + * This API intentionally does **not** return a value or Promise. + * + * Live Preview connections are considered unreliable: + * - Multiple Live Preview clients may exist, or none at all. + * - Clients may be geographically distant and slow to respond. + * - If a Live Preview disconnects unexpectedly, Phoenix may take up to ~10 seconds + * to detect the disconnection, during which calls would otherwise block. + * + * Because of these constraints, this function does **not** wait for acknowledgements + * or responses, and callers should **not** rely on timely execution or delivery. + * + * Use this method only for best-effort notifications or side effects in Live Preview. + * + * If a response or guaranteed delivery is required, invoke this function using + * `triggerLPFn()` and have the corresponding Live Preview handler explicitly + * send the result back to Phoenix via `PhoenixComm.execFn`. + * + * @param {string} fnName + * Name of the Live Preview API function to invoke. + * + * @param {*} fnArgs + * Arguments to pass to the Live Preview function(object/string). Must be JSON-serializable. + * + * @param {number|number[]=} clientIdOrArray + * Optional client ID or array of client IDs obtained from getConnectionIds(). + * If omitted, the function is executed for all active Live Preview connections. + */ + function triggerLPFn(fnName, fnArgs, clientIdOrArray) { + let clientIds; + + if (clientIdOrArray === undefined || clientIdOrArray === null) { + clientIds = getConnectionIds(); + } else if (Array.isArray(clientIdOrArray)) { + clientIds = clientIdOrArray; + } else { + clientIds = [clientIdOrArray]; + } + + clientIds.map(clientId => { + _transport.send([clientId], JSON.stringify({ + method: "PhoenixComm.execLPFn", + fnName, + params: fnArgs + })); + }); + } + + + window.ee= triggerLPFn; // todo remove this once all usages are migrated to execLPFn + /** * Closes the connection to the given client. Proxies to the transport. * @param {number} clientId @@ -514,6 +578,9 @@ define(function (require, exports, module) { exports.closeAllConnections = closeAllConnections; exports.setLivePreviewMessageHandler = setLivePreviewMessageHandler; exports.setCustomRemoteFunctionProvider = setCustomRemoteFunctionProvider; + // lp communication functions + exports.registerPhoenixFn = registerPhoenixFn; + exports.triggerLPFn = triggerLPFn; exports.LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME = LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME; exports.LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME = LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME; exports.EVENT_LIVE_PREVIEW_CLICKED = EVENT_LIVE_PREVIEW_CLICKED; From f3e63d0a5847db6fbd5a6eaacc1788c30b564343 Mon Sep 17 00:00:00 2001 From: abose Date: Tue, 23 Dec 2025 10:50:19 +0530 Subject: [PATCH 2/3] fix: memory leak in live preview legacy send handlers --- .../BrowserScripts/RemoteFunctions.js | 2 +- .../protocol/LiveDevProtocol.js | 114 ++++++++++++++++-- 2 files changed, 104 insertions(+), 12 deletions(-) diff --git a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js index f00a295e0..b89715a76 100644 --- a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js +++ b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js @@ -17,7 +17,7 @@ function RemoteFunctions(config = {}) { // this is for bidirectional communication between phoenix and live preview const PhoenixComm = window._Brackets_LiveDev_PhoenixComm; - PhoenixComm.registerLpFn("PH_Hello", function(param) { + PhoenixComm && PhoenixComm.registerLpFn("PH_Hello", function(param) { // this is just a test function here to check if live preview. fn call is working correctly. console.log("Hello World", param); }); diff --git a/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js b/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js index c81f47c30..37407dc8f 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js +++ b/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js @@ -86,10 +86,84 @@ define(function (require, exports, module) { /** * @private - * A map of response IDs to deferreds, for messages that are awaiting responses. - * @type {Object} + * LRU cache of response IDs to deferreds, for messages that are awaiting responses. + * Uses LRU cache to prevent unbounded memory growth. + * @type {Phoenix.libs.LRUCache} + */ + const pendingLpResponses = new Phoenix.libs.LRUCache({ + max: MAX_PENDING_LP_CALLS_1000 + }); + + /** + * @private + * Reverse mapping: clientId -> Set of pending call IDs. + * Used to clean up pending calls when a client disconnects. + * @type {Map>} + */ + const pendingCallsByClient = new Map(); + + /** + * Track a pending call for cleanup when client disconnects. + * @param {number} fnCallID + * @param {number} clientId + * @param {$.Deferred} deferred */ - var _responseDeferreds = {}; + function _trackPending(fnCallID, clientId, deferred) { + pendingLpResponses.set(fnCallID, { deferred, clientId }); + + let set = pendingCallsByClient.get(clientId); + if (!set) { + set = new Set(); + pendingCallsByClient.set(clientId, set); + } + set.add(fnCallID); + } + + /** + * Untrack a pending call (cleanup from both maps). + * @param {number} fnCallID + */ + function _untrackPending(fnCallID) { + const pendingHandler = pendingLpResponses.get(fnCallID); + if (!pendingHandler) { + return; + } + + // Delete from main cache + pendingLpResponses.delete(fnCallID); + + // Clean up reverse mapping + if (pendingHandler.clientId) { + const set = pendingCallsByClient.get(pendingHandler.clientId); + if (set) { + set.delete(fnCallID); + if (set.size === 0) { + pendingCallsByClient.delete(pendingHandler.clientId); + } + } + } + + return pendingHandler; + } + + /** + * Reject all pending calls for a Live Preview client that disconnected. + * @param {number} clientId + */ + function rejectAllPendingForClient(clientId) { + const set = pendingCallsByClient.get(clientId); + if (!set || set.size === 0) { + return; + } + + const callIds = Array.from(set); + for (const fnCallID of callIds) { + const pendingHandler = _untrackPending(fnCallID); + if (pendingHandler) { + pendingHandler.deferred.reject(new Error(`Live Preview client disconnected: ${clientId}`)); + } + } + } let _remoteFunctionProvider = null; @@ -238,7 +312,6 @@ define(function (require, exports, module) { function _receive(clientId, msgStr, messageID) { const msg = JSON.parse(msgStr), event = msg.method || "event"; - let deferred; if(messageID && processedMessageIDs.has(messageID)){ return; // this message is already processed. } else if (messageID) { @@ -256,13 +329,12 @@ define(function (require, exports, module) { } if (msg.id) { - deferred = _responseDeferreds[msg.id]; - if (deferred) { - delete _responseDeferreds[msg.id]; + const pendingHandler = _untrackPending(msg.id); + if (pendingHandler) { if (msg.error) { - deferred.reject(msg); + pendingHandler.deferred.reject(msg); } else { - deferred.resolve(msg); + pendingHandler.deferred.resolve(msg); } } } else if (msg.clicked && msg.tagId) { @@ -280,7 +352,7 @@ define(function (require, exports, module) { * Dispatches a message to the remote protocol handler via the transport. * * @param {Object} msg The message to send. - * @param {number|Array.} idOrArray ID or IDs of the client(s) that should + * @param {number|Array.} clients ID or IDs of the client(s) that should * receive the message. * @return {$.Promise} A promise that's fulfilled when the response to the message is received. */ @@ -296,7 +368,22 @@ define(function (require, exports, module) { // broadcast if there are no specific clients clients = clients || getConnectionIds(); msg.id = id; - _responseDeferreds[id] = result; // todo responses deffered if size larger than 100k enttries raise metric and warn in console once every 10 seconds long only + + // Normalize clients to array + if (!Array.isArray(clients)) { + clients = [clients]; + } + + // If no clients available, reject immediately + if (clients.length === 0) { + result.reject(new Error("No live preview clients connected")); + return result.promise(); + } + + // Track pending call for the first client (representative) + const clientId = clients[0]; + _trackPending(id, clientId, result); + _transport.send(clients, JSON.stringify(msg)); return result.promise(); } @@ -329,6 +416,10 @@ define(function (require, exports, module) { if(!_connections[clientId]){ return; } + + // Reject all pending calls for this client + rejectAllPendingForClient(clientId); + delete _connections[clientId]; exports.trigger("ConnectionClose", { clientId: clientId @@ -559,6 +650,7 @@ define(function (require, exports, module) { function closeAllConnections() { getConnectionIds().forEach(function (clientId) { close(clientId); + rejectAllPendingForClient(clientId); }); _connections = {}; } From d737c97c79246a84f2e6e9780310b76692e20993 Mon Sep 17 00:00:00 2001 From: abose Date: Tue, 23 Dec 2025 11:18:57 +0530 Subject: [PATCH 3/3] fix: newly added features md comes up repeatedly on delete --- .../Phoenix/newly-added-features.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/extensionsIntegrated/Phoenix/newly-added-features.js b/src/extensionsIntegrated/Phoenix/newly-added-features.js index 85e5169eb..b93647931 100644 --- a/src/extensionsIntegrated/Phoenix/newly-added-features.js +++ b/src/extensionsIntegrated/Phoenix/newly-added-features.js @@ -25,8 +25,12 @@ define(function (require, exports, module) { DocumentManager = require("document/DocumentManager"), FileSystem = require("filesystem/FileSystem"), FileUtils = require("file/FileUtils"), + StringUtils = require("utils/StringUtils"), + StateManager = require("preferences/StateManager"), Metrics = require("utils/Metrics"); + const STATE_LAST_SHOWN_HASH = "newlyAddedFeaturesHash"; + function _getUpdateMarkdownURL() { return Phoenix.baseURL + "assets/default-project/en/Newly_added_features.md"; } @@ -66,8 +70,14 @@ define(function (require, exports, module) { async function _showNewUpdatesIfPresent() { // codemirror documents are always \n instead of \r\n line endings. so we strip here too let newMarkdownText = (await _getUpdateMarkdownText()).replace(/\r/g, ''); + let newMarkdownTextHash = StringUtils.hashCode(newMarkdownText); + if(StateManager.get(STATE_LAST_SHOWN_HASH) === newMarkdownTextHash){ + // already shown this update, so no need to show it again. + return; + } let currentMarkdownText = (await _readMarkdownTextFile()).replace(/\r/g, ''); if(newMarkdownText !== currentMarkdownText){ + StateManager.set(STATE_LAST_SHOWN_HASH, newMarkdownTextHash); let markdownFile = FileSystem.getFileForPath(_getUpdateMarkdownLocalPath()); // if the user overwrites the markdown file, then the user edited content will be nuked here. FileUtils.writeText(markdownFile, newMarkdownText, true) @@ -80,7 +90,8 @@ define(function (require, exports, module) { exports.init = function () { if(!Phoenix.firstBoot && !window.testEnvironment){ - _showNewUpdatesIfPresent(); + _showNewUpdatesIfPresent() + .catch(console.error); // fine if we dont show it once. happens mostly when offline. } }; });