From b243171f46b38a0e5a7f2654b29f8692afebd75e Mon Sep 17 00:00:00 2001 From: stefanrammo Date: Mon, 6 Apr 2026 12:41:05 +0300 Subject: [PATCH] Add DISCONNECT structure event for app connection loss Adds DISCONNECT (3) to studio.api.structure to distinguish app going offline from child node removal (REMOVE): - DISCONNECT (3) fires at root level when an app goes offline - REMOVE (0) fires at node level when a child is removed - RECONNECT (2) fires at root level when a disconnected app returns Also populates connectionLocalApps for the primary connection in proxy mode. Previously it was only populated in direct mode, so the primary app never fired DISCONNECT or RECONNECT when its connection dropped in proxy mode. CDP-6069 --- README.rst | 20 ++++++++++------- index.js | 31 +++++++++++++------------- test/find-and-structure-events.test.js | 17 +++++++------- 3 files changed, 37 insertions(+), 31 deletions(-) diff --git a/README.rst b/README.rst index efb93c5..cb3a328 100644 --- a/README.rst +++ b/README.rst @@ -66,14 +66,17 @@ Benefits Structure Events ---------------- -On the root node, ``subscribeToStructure`` tracks application lifecycle with three event types: +On the root node, ``subscribeToStructure`` tracks application lifecycle: - ``studio.api.structure.ADD`` (1) — An application appeared for the first time -- ``studio.api.structure.REMOVE`` (0) — An application went offline +- ``studio.api.structure.DISCONNECT`` (3) — An application went offline (may reconnect) - ``studio.api.structure.RECONNECT`` (2) — An application restarted (was seen before, went offline, came back) -On other nodes, ADD and REMOVE fire when children are added or removed at runtime. -RECONNECT only fires at the root level. +On other nodes, ``subscribeToStructure`` fires when children are added or removed +(e.g. components or operators added to a running application): + +- ``studio.api.structure.ADD`` (1) — A child node was added +- ``studio.api.structure.REMOVE`` (0) — A child node was removed When an app restarts, the client automatically restores value and event subscriptions, so user code does not need to re-subscribe. RECONNECT is informational — use it for @@ -89,7 +92,7 @@ logging or UI updates. node.subscribeToValues(v => console.log(`[${appName}] CPULoad: ${v}`)); }).catch(err => console.error(`Failed to find ${appName}.CPULoad:`, err)); } - if (change === studio.api.structure.REMOVE) { + if (change === studio.api.structure.DISCONNECT) { console.log(`App offline: ${appName}`); } if (change === studio.api.structure.RECONNECT) { @@ -656,9 +659,10 @@ node.subscribeToStructure(structureConsumer) - Usage - Subscribe to structure changes on this node. Each time a child is added or removed, - structureConsumer is called with the child name and change (ADD == 1, REMOVE == 0). - On the root node, RECONNECT (2) fires when a previously-seen application restarts. + Subscribe to structure changes on this node. + On the root node: ADD (1) when an app appears, DISCONNECT (3) when it goes offline, + RECONNECT (2) when it restarts. On other nodes: ADD (1) when a child is added, + REMOVE (0) when a child is removed. node.unsubscribeFromStructure(structureConsumer) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/index.js b/index.js index f7cc9cc..c89d6ac 100644 --- a/index.js +++ b/index.js @@ -456,7 +456,8 @@ studio.internal = (function(proto) { obj.structure = { REMOVE: 0, ADD: 1, - RECONNECT: 2 + RECONNECT: 2, + DISCONNECT: 3 }; const STRUCTURE_REQUEST_TIMEOUT_MS = 30000; @@ -803,7 +804,7 @@ studio.internal = (function(proto) { var everSeenApps = new Set(); var pendingFindWaiters = []; // for find() waiting on late apps var pendingFetches = []; - var connectionLocalApps = new Map(); // Maps AppConnection → local app name (direct mode) + var connectionLocalApps = new Map(); // Maps AppConnection → local app name var this_ = this; function isApplicationNode(node) { @@ -907,7 +908,7 @@ studio.internal = (function(proto) { function unannounceApp(appName) { if (!announcedApps.has(appName)) return; announcedApps.delete(appName); - notifyStructure(appName, obj.structure.REMOVE); + notifyStructure(appName, obj.structure.DISCONNECT); } function notifyApplications(connection) { @@ -924,8 +925,15 @@ studio.internal = (function(proto) { var primaryConn = appConnections[0]; var isProxyMode = primaryConn && primaryConn.supportsProxyProtocol(); + // Track which app this connection owns (needed for DISCONNECT on connection loss) + system.forEachChild(function(app) { + if (isApplicationNode(app)) { + connectionLocalApps.set(connection, app.name()); + } + }); + if (isProxyMode) { - // Proxy mode: only handle REMOVE here. ADD/RECONNECT is deferred to + // Proxy mode: handle server-side child REMOVE here. ADD/RECONNECT is deferred to // notifyApplications() after the proxy tunnel connects (via // tryConnectPendingSiblings → connectViaProxy), ensuring the sibling // is actually reachable before announcing it. @@ -934,14 +942,6 @@ studio.internal = (function(proto) { unannounceApp(appName); } }); - } else { - // Direct mode: each connection owns its local app. - // Connection lifecycle directly maps to app lifecycle. - system.forEachChild(function(app) { - if (isApplicationNode(app)) { - connectionLocalApps.set(connection, app.name()); - } - }); } resolve(system); @@ -953,7 +953,7 @@ studio.internal = (function(proto) { var appConnection = new obj.AppConnection(url, notificationListener, autoConnect); appConnections.push(appConnection); - // Direct mode lifecycle: connection close → REMOVE, reconnect → RECONNECT + // Direct mode lifecycle: connection close → DISCONNECT, reconnect → RECONNECT appConnection.onDisconnected = function() { var localApp = connectionLocalApps.get(appConnection); if (localApp) unannounceApp(localApp); @@ -2431,7 +2431,8 @@ studio.api = (function(internal) { * * @callback structureConsumer * @param {string} node name - * @param {number} change - ADD (1), REMOVE (0), or RECONNECT (2) from studio.api.structure + * @param {number} change - At root level: ADD (1), DISCONNECT (3), or RECONNECT (2). + * At other nodes: ADD (1) or REMOVE (0). See studio.api.structure. */ /** @@ -2547,7 +2548,7 @@ studio.api = (function(internal) { var findNodeCacheInvalidator = null; // Set after findNodeCache is created var system = new internal.SystemNode(studioURL, notificationListener, function(appName) { - // Called on app structure changes (ADD, REMOVE, or RECONNECT) + // Called on app structure changes (ADD, DISCONNECT, or RECONNECT) findNodeCacheInvalidator(appName); }); diff --git a/test/find-and-structure-events.test.js b/test/find-and-structure-events.test.js index ff2df8f..97db6af 100644 --- a/test/find-and-structure-events.test.js +++ b/test/find-and-structure-events.test.js @@ -1,25 +1,26 @@ /** - * find() wait semantics and RECONNECT structure event tests + * find() wait semantics and structure event constant tests * - * Unit tests for the new public API surfaces. Tests that require + * Unit tests for the public API surfaces. Tests that require * a live connection (find() timeout behavior, subscribeToStructure - * RECONNECT events) are covered by the component tests in the + * lifecycle events) are covered by the component tests in the * parent cdp monorepo. */ global.WebSocket = require('ws'); const studio = require('../index'); -describe('RECONNECT structure constant', () => { - test('studio.api.structure has ADD, REMOVE, and RECONNECT with correct values', () => { +describe('structure event constants', () => { + test('studio.api.structure has ADD, REMOVE, RECONNECT, and DISCONNECT with correct values', () => { expect(studio.api.structure.ADD).toBe(1); expect(studio.api.structure.REMOVE).toBe(0); expect(studio.api.structure.RECONNECT).toBe(2); + expect(studio.api.structure.DISCONNECT).toBe(3); }); - test('RECONNECT is distinct from ADD and REMOVE', () => { - const values = [studio.api.structure.ADD, studio.api.structure.REMOVE, studio.api.structure.RECONNECT]; - expect(new Set(values).size).toBe(3); + test('all structure constants are distinct', () => { + const values = [studio.api.structure.ADD, studio.api.structure.REMOVE, studio.api.structure.RECONNECT, studio.api.structure.DISCONNECT]; + expect(new Set(values).size).toBe(4); }); });