From 7a1bca075d6bbcfbd72901e107c8396818c7dd15 Mon Sep 17 00:00:00 2001 From: kunal0137 Date: Fri, 23 Oct 2026 21:02:36 +0000 Subject: [PATCH 1/5] feat(openshell): keep the user's sandbox leased while any session is active Every authenticated request refreshes the user's sandbox lease (at most once a minute per session, for the session timeout). A session ending releases its lease; the sandbox goes when the user's last session does, rather than with whichever session happened to start it. Co-Authored-By: Claude Opus 5.5 --- .../web/conf/NoUserInSessionFilter.java | 10 +++++++ src/prerna/web/conf/UserSessionLoader.java | 30 ++++++++++++------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/prerna/web/conf/NoUserInSessionFilter.java b/src/prerna/web/conf/NoUserInSessionFilter.java index ce1880d2..c2a46442 100644 --- a/src/prerna/web/conf/NoUserInSessionFilter.java +++ b/src/prerna/web/conf/NoUserInSessionFilter.java @@ -46,11 +46,13 @@ import prerna.semoss.web.services.local.ResourceUtility; import prerna.util.Constants; import prerna.util.SocialPropertiesUtil; +import prerna.util.openshell.OpenShellUserSandboxes; import prerna.web.services.util.WebUtility; public class NoUserInSessionFilter implements Filter { private static final String NO_USER_HTML = "/noUserFail/"; + private static final String OPENSHELL_LEASE_ATTR = "OPENSHELL_SANDBOX_LEASE"; @Override public void doFilter(ServletRequest arg0, ServletResponse arg1, FilterChain arg2) @@ -76,6 +78,14 @@ public void doFilter(ServletRequest arg0, ServletResponse arg1, FilterChain arg2 // don't know what i am redirecting // send back an error and have the client remake the request setInvalidEntryRedirect(context, arg0, arg1); + } else if (session != null && OpenShellUserSandboxes.enabled()) { + // any activity, not only python, keeps the user's sandbox alive for the + // session timeout; refreshed at most once a minute per session + String marker = OpenShellUserSandboxes.touch(user, session.getId(), + (String) session.getAttribute(OPENSHELL_LEASE_ATTR), session.getMaxInactiveInterval()); + if (marker != null) { + session.setAttribute(OPENSHELL_LEASE_ATTR, marker); + } } } diff --git a/src/prerna/web/conf/UserSessionLoader.java b/src/prerna/web/conf/UserSessionLoader.java index 706fb0cb..d99acbde 100644 --- a/src/prerna/web/conf/UserSessionLoader.java +++ b/src/prerna/web/conf/UserSessionLoader.java @@ -63,6 +63,7 @@ import prerna.util.SymlinkHelper; import prerna.util.Utility; import prerna.util.insight.InsightUtility; +import prerna.util.openshell.OpenShellUserSandboxes; @WebListener public class UserSessionLoader implements HttpSessionListener { @@ -200,11 +201,11 @@ public Void call() throws Exception { } // stop netty/python wrappers and chroot mounts for session user - cleanupUserProcessAndChroot(thisUser, "session user"); + cleanupUserProcessAndChroot(thisUser, sessionId, "session user"); // remove agent users if (subAgent != thisUser) { - cleanupUserProcessAndChroot(subAgent, "agent user"); + cleanupUserProcessAndChroot(subAgent, sessionId, "agent user"); } // if cloud sync enabled, push and clear the rooms @@ -236,19 +237,28 @@ public Void call() throws Exception { /** * Cleans up user-owned client process and optional chroot resources. * - * @param user the user to clean up - * @param userType descriptive label used in logs (for example, session user) + * @param user the user to clean up + * @param sessionId the session that ended + * @param userType descriptive label used in logs (for example, session user) */ - private void cleanupUserProcessAndChroot(User user, String userType) { + private void cleanupUserProcessAndChroot(User user, String sessionId, String userType) { if (user == null) { return; } + boolean openshellUserSandbox = OpenShellUserSandboxes.enabled(); try { - // stop the netty thread if used for either r or python - ClientProcessWrapper cpw = user.getPythonClientProcessWrapper(); - if (cpw != null) { - cpw.shutdown(true); + if (openshellUserSandbox) { + // The python worker and chroot belong to the user, not this session: they + // go when the user's last session here ends, and the sandbox when their + // last session anywhere does. + OpenShellUserSandboxes.sessionEnded(user, sessionId); + } else { + // stop the netty thread if used for either r or python + ClientProcessWrapper cpw = user.getPythonClientProcessWrapper(); + if (cpw != null) { + cpw.shutdown(true); + } } } catch (Exception e) { classLogger.error("Failed to shut down client process wrapper during {} cleanup", userType, e); @@ -266,7 +276,7 @@ private void cleanupUserProcessAndChroot(User user, String userType) { // remove mounts if chroot is enabled try { - if (Boolean.parseBoolean(Utility.getDIHelperProperty(Constants.CHROOT_ENABLE))) { + if (!openshellUserSandbox && Boolean.parseBoolean(Utility.getDIHelperProperty(Constants.CHROOT_ENABLE))) { SymlinkHelper chrootHelper = user.getUserSymlinkHelper(); if (chrootHelper != null) { chrootHelper.removeChrootFolder(); From e22bd5ba23951b6b4e6399758cd448e4875f64e8 Mon Sep 17 00:00:00 2001 From: kunal0137 Date: Fri, 23 Oct 2026 21:46:46 +0000 Subject: [PATCH 2/5] fix(openshell): tell a server stop apart from a user leaving sessionDestroyed also fires when Tomcat stops. Treat only a logout or an idle timeout as the session being over; otherwise keep the sandbox lease and do not clear the insight's python variables, which live on in the user's worker. Co-Authored-By: Claude Opus 5.5 --- src/prerna/web/conf/UserSessionLoader.java | 40 +++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/src/prerna/web/conf/UserSessionLoader.java b/src/prerna/web/conf/UserSessionLoader.java index d99acbde..c0d506a9 100644 --- a/src/prerna/web/conf/UserSessionLoader.java +++ b/src/prerna/web/conf/UserSessionLoader.java @@ -97,6 +97,10 @@ public void sessionDestroyed(HttpSessionEvent sessionEvent) { classLogger.info("Starting logout"); HttpSession session = sessionEvent.getSession(); String sessionId = session.getId(); + // A user sandbox outlives a server stop, which also destroys every session: only a + // logout or a real timeout ends the user's claim on it (see OpenShellUserSandboxes). + boolean openshellUserSandbox = OpenShellUserSandboxes.enabled(); + boolean sessionOver = !openshellUserSandbox || sessionIsOver(session); User thisUser = (User) session.getAttribute(Constants.SESSION_USER); if (thisUser == null) { @@ -135,6 +139,10 @@ public void sessionDestroyed(HttpSessionEvent sessionEvent) { } classLogger.info("Trying to drop insight {}", insightId); try { + if (!sessionOver) { + // the user's python outlives this server; keep the insight's variables + insight.setDeletePythonGlobalsOnDropInsight(false); + } InsightUtility.dropInsight(insight); classLogger.info("Dropped insight {}", insightId); } catch (Exception e) { @@ -201,11 +209,11 @@ public Void call() throws Exception { } // stop netty/python wrappers and chroot mounts for session user - cleanupUserProcessAndChroot(thisUser, sessionId, "session user"); + cleanupUserProcessAndChroot(thisUser, sessionId, sessionOver, "session user"); // remove agent users if (subAgent != thisUser) { - cleanupUserProcessAndChroot(subAgent, sessionId, "agent user"); + cleanupUserProcessAndChroot(subAgent, sessionId, sessionOver, "agent user"); } // if cloud sync enabled, push and clear the rooms @@ -239,9 +247,10 @@ public Void call() throws Exception { * * @param user the user to clean up * @param sessionId the session that ended + * @param over true for a logout or timeout, false when the server is stopping * @param userType descriptive label used in logs (for example, session user) */ - private void cleanupUserProcessAndChroot(User user, String sessionId, String userType) { + private void cleanupUserProcessAndChroot(User user, String sessionId, boolean over, String userType) { if (user == null) { return; } @@ -252,7 +261,7 @@ private void cleanupUserProcessAndChroot(User user, String sessionId, String use // The python worker and chroot belong to the user, not this session: they // go when the user's last session here ends, and the sandbox when their // last session anywhere does. - OpenShellUserSandboxes.sessionEnded(user, sessionId); + OpenShellUserSandboxes.sessionEnded(user, sessionId, over); } else { // stop the netty thread if used for either r or python ClientProcessWrapper cpw = user.getPythonClientProcessWrapper(); @@ -397,4 +406,27 @@ private void cleanupPlaywrightSessions(User thisUser) { } } + + /** + * Whether the user has really left: an explicit logout, or idle past the session + * timeout. Anything else that destroys a session -- a server stop above all -- is not + * the user leaving. + * + * @param session the session being destroyed + * @return true for a logout or a timeout + */ + private static boolean sessionIsOver(HttpSession session) { + try { + if (Boolean.parseBoolean(session.getAttribute(UserSessionLoader.IS_USER_LOGOUT) + "")) { + return true; + } + int maxInactive = session.getMaxInactiveInterval(); + long idleMs = System.currentTimeMillis() - session.getLastAccessedTime(); + // the container expires a session a little after its interval, never before + return maxInactive > 0 && idleMs >= maxInactive * 1000L - 5000L; + } catch (IllegalStateException e) { + // unreadable: keep the lease; the sweeper deletes it once it lapses + return false; + } + } } From a75bb15ed6d67db7bc64bed43d5701856fda939e Mon Sep 17 00:00:00 2001 From: kunal0137 Date: Sat, 24 Oct 2026 01:55:08 +0000 Subject: [PATCH 3/5] refactor(openshell): gate session cleanup on appliesTo and tidy sessionIsOver Users without a primary login keep the per-session cleanup; the expiry slack is a named constant. Co-Authored-By: Claude Opus 5.5 --- src/prerna/web/conf/NoUserInSessionFilter.java | 2 +- src/prerna/web/conf/UserSessionLoader.java | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/prerna/web/conf/NoUserInSessionFilter.java b/src/prerna/web/conf/NoUserInSessionFilter.java index c2a46442..9401100f 100644 --- a/src/prerna/web/conf/NoUserInSessionFilter.java +++ b/src/prerna/web/conf/NoUserInSessionFilter.java @@ -78,7 +78,7 @@ public void doFilter(ServletRequest arg0, ServletResponse arg1, FilterChain arg2 // don't know what i am redirecting // send back an error and have the client remake the request setInvalidEntryRedirect(context, arg0, arg1); - } else if (session != null && OpenShellUserSandboxes.enabled()) { + } else if (session != null && OpenShellUserSandboxes.appliesTo(user)) { // any activity, not only python, keeps the user's sandbox alive for the // session timeout; refreshed at most once a minute per session String marker = OpenShellUserSandboxes.touch(user, session.getId(), diff --git a/src/prerna/web/conf/UserSessionLoader.java b/src/prerna/web/conf/UserSessionLoader.java index c0d506a9..a6d3b9ee 100644 --- a/src/prerna/web/conf/UserSessionLoader.java +++ b/src/prerna/web/conf/UserSessionLoader.java @@ -68,6 +68,9 @@ @WebListener public class UserSessionLoader implements HttpSessionListener { + /** How far short of its timeout an idle session still counts as timed out. */ + private static final long SESSION_EXPIRY_SLACK_MS = 5000L; + public static final String IS_USER_LOGOUT = "IS_USER_LOGOUT"; public static final String USER_LOGOUT_IP_ADDR = "USER_LOGOUT_IP_ADDR"; @@ -97,12 +100,11 @@ public void sessionDestroyed(HttpSessionEvent sessionEvent) { classLogger.info("Starting logout"); HttpSession session = sessionEvent.getSession(); String sessionId = session.getId(); - // A user sandbox outlives a server stop, which also destroys every session: only a - // logout or a real timeout ends the user's claim on it (see OpenShellUserSandboxes). - boolean openshellUserSandbox = OpenShellUserSandboxes.enabled(); - boolean sessionOver = !openshellUserSandbox || sessionIsOver(session); User thisUser = (User) session.getAttribute(Constants.SESSION_USER); + // A user sandbox outlives a server stop, which also destroys every session: only a + // logout or a real timeout ends the user's claim on it (see OpenShellUserSandboxes). + boolean sessionOver = !OpenShellUserSandboxes.appliesTo(thisUser) || sessionIsOver(session); if (thisUser == null) { // no need to log a new session that is auto dropped // this just keeps writing to the log @@ -255,7 +257,7 @@ private void cleanupUserProcessAndChroot(User user, String sessionId, boolean ov return; } - boolean openshellUserSandbox = OpenShellUserSandboxes.enabled(); + boolean openshellUserSandbox = OpenShellUserSandboxes.appliesTo(user); try { if (openshellUserSandbox) { // The python worker and chroot belong to the user, not this session: they @@ -406,7 +408,6 @@ private void cleanupPlaywrightSessions(User thisUser) { } } - /** * Whether the user has really left: an explicit logout, or idle past the session * timeout. Anything else that destroys a session -- a server stop above all -- is not @@ -417,13 +418,13 @@ private void cleanupPlaywrightSessions(User thisUser) { */ private static boolean sessionIsOver(HttpSession session) { try { - if (Boolean.parseBoolean(session.getAttribute(UserSessionLoader.IS_USER_LOGOUT) + "")) { + if (Boolean.parseBoolean(String.valueOf(session.getAttribute(UserSessionLoader.IS_USER_LOGOUT)))) { return true; } int maxInactive = session.getMaxInactiveInterval(); long idleMs = System.currentTimeMillis() - session.getLastAccessedTime(); // the container expires a session a little after its interval, never before - return maxInactive > 0 && idleMs >= maxInactive * 1000L - 5000L; + return maxInactive > 0 && idleMs >= maxInactive * 1000L - SESSION_EXPIRY_SLACK_MS; } catch (IllegalStateException e) { // unreadable: keep the lease; the sweeper deletes it once it lapses return false; From 544dc6ba8589ebd03c22ec6eb3b7636ccc44a7da Mon Sep 17 00:00:00 2001 From: Kunal Patel Date: Fri, 23 Oct 2026 23:06:24 -0400 Subject: [PATCH 4/5] fix(openshell): count app-ended sessions as over, not as a server stop --- .../web/services/local/SessionResource.java | 30 ++++++++++--------- src/prerna/web/conf/UserSessionLoader.java | 13 ++++---- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/prerna/semoss/web/services/local/SessionResource.java b/src/prerna/semoss/web/services/local/SessionResource.java index 64a4bb2e..afceb683 100644 --- a/src/prerna/semoss/web/services/local/SessionResource.java +++ b/src/prerna/semoss/web/services/local/SessionResource.java @@ -59,6 +59,7 @@ import prerna.util.Constants; import prerna.util.insight.InsightUtility; import prerna.web.conf.DBLoader; +import prerna.web.conf.UserSessionLoader; import prerna.web.services.util.WebUtility; @Path("/session") @@ -183,22 +184,14 @@ public Response cleanSession(@Context HttpServletRequest request) { synchronized (lock) { cancelTime = (Date) session.getAttribute(CANCEL_INVALIDATION); } - if (cancelTime == null) { + if (cancelTime != null && execTime.before(cancelTime)) { + classLogger.info("Cancelled invalidating session"); + output = "cancelled"; + } else { // kill the entire session classLogger.info("Invalidating session"); - session.invalidate(); + endSession(session); output = "invalidated"; - } else { - boolean isCancelled = execTime.before(cancelTime); - if (isCancelled) { - classLogger.info("Cancelled invalidating session"); - output = "cancelled"; - } else { - // kill the entire session - classLogger.info("Invalidating session"); - session.invalidate(); - output = "invalidated"; - } } } else { // in case during the time this was called @@ -291,10 +284,19 @@ public void invalidateSession(@Context HttpServletRequest request, @Context Http if (session != null) { classLogger.info("Invalidating session"); - session.invalidate(); + endSession(session); } } + /** + * Invalidate a live session on purpose, so session cleanup treats it as over (releasing + * the user's sandbox lease) rather than as a server stop. + */ + private static void endSession(HttpSession session) { + session.setAttribute(UserSessionLoader.IS_SESSION_ENDED, true); + session.invalidate(); + } + @GET @Path("/insights") @Produces("application/json;charset=utf-8") diff --git a/src/prerna/web/conf/UserSessionLoader.java b/src/prerna/web/conf/UserSessionLoader.java index a6d3b9ee..ce83a450 100644 --- a/src/prerna/web/conf/UserSessionLoader.java +++ b/src/prerna/web/conf/UserSessionLoader.java @@ -72,6 +72,8 @@ public class UserSessionLoader implements HttpSessionListener { private static final long SESSION_EXPIRY_SLACK_MS = 5000L; public static final String IS_USER_LOGOUT = "IS_USER_LOGOUT"; + // set when the app invalidates a live session on purpose (not a logout, not a server stop) + public static final String IS_SESSION_ENDED = "IS_SESSION_ENDED"; public static final String USER_LOGOUT_IP_ADDR = "USER_LOGOUT_IP_ADDR"; private static final Logger classLogger = LogManager.getLogger(UserSessionLoader.class); @@ -409,16 +411,17 @@ private void cleanupPlaywrightSessions(User thisUser) { } /** - * Whether the user has really left: an explicit logout, or idle past the session - * timeout. Anything else that destroys a session -- a server stop above all -- is not - * the user leaving. + * Whether the user has really left: an explicit logout, a session the app ended on + * purpose, or idle past the session timeout. Anything else that destroys a session -- + * a server stop above all -- is not the user leaving. * * @param session the session being destroyed - * @return true for a logout or a timeout + * @return true for a logout, an app-ended session or a timeout */ private static boolean sessionIsOver(HttpSession session) { try { - if (Boolean.parseBoolean(String.valueOf(session.getAttribute(UserSessionLoader.IS_USER_LOGOUT)))) { + if (Boolean.parseBoolean(String.valueOf(session.getAttribute(UserSessionLoader.IS_USER_LOGOUT))) + || Boolean.parseBoolean(String.valueOf(session.getAttribute(UserSessionLoader.IS_SESSION_ENDED)))) { return true; } int maxInactive = session.getMaxInactiveInterval(); From 25acbf67bb53fc82ec151534a7aa4a50bc229a6f Mon Sep 17 00:00:00 2001 From: kunal0137 Date: Sat, 24 Oct 2026 11:33:40 +0000 Subject: [PATCH 5/5] fix(openshell): release the sandbox lease on a provider logout The filter records the sandbox owner on the session while the user is logged in; session cleanup uses it, because a provider logout has already dropped the token the owner was derived from. --- .../web/conf/NoUserInSessionFilter.java | 3 +++ src/prerna/web/conf/UserSessionLoader.java | 25 +++++++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/prerna/web/conf/NoUserInSessionFilter.java b/src/prerna/web/conf/NoUserInSessionFilter.java index 9401100f..82850f24 100644 --- a/src/prerna/web/conf/NoUserInSessionFilter.java +++ b/src/prerna/web/conf/NoUserInSessionFilter.java @@ -85,6 +85,9 @@ public void doFilter(ServletRequest arg0, ServletResponse arg1, FilterChain arg2 (String) session.getAttribute(OPENSHELL_LEASE_ATTR), session.getMaxInactiveInterval()); if (marker != null) { session.setAttribute(OPENSHELL_LEASE_ATTR, marker); + // kept for logout: dropping a provider can leave the user with no login + // to derive the owner from by the time the session is destroyed + session.setAttribute(OpenShellUserSandboxes.SESSION_OWNER_ATTR, OpenShellUserSandboxes.ownerOf(user)); } } } diff --git a/src/prerna/web/conf/UserSessionLoader.java b/src/prerna/web/conf/UserSessionLoader.java index ce83a450..b6994dec 100644 --- a/src/prerna/web/conf/UserSessionLoader.java +++ b/src/prerna/web/conf/UserSessionLoader.java @@ -106,7 +106,9 @@ public void sessionDestroyed(HttpSessionEvent sessionEvent) { User thisUser = (User) session.getAttribute(Constants.SESSION_USER); // A user sandbox outlives a server stop, which also destroys every session: only a // logout or a real timeout ends the user's claim on it (see OpenShellUserSandboxes). - boolean sessionOver = !OpenShellUserSandboxes.appliesTo(thisUser) || sessionIsOver(session); + String openshellOwner = OpenShellUserSandboxes.ownerForSession(thisUser, + session.getAttribute(OpenShellUserSandboxes.SESSION_OWNER_ATTR)); + boolean sessionOver = openshellOwner == null || sessionIsOver(session); if (thisUser == null) { // no need to log a new session that is auto dropped // this just keeps writing to the log @@ -213,11 +215,12 @@ public Void call() throws Exception { } // stop netty/python wrappers and chroot mounts for session user - cleanupUserProcessAndChroot(thisUser, sessionId, sessionOver, "session user"); + cleanupUserProcessAndChroot(thisUser, openshellOwner, sessionId, sessionOver, "session user"); // remove agent users if (subAgent != thisUser) { - cleanupUserProcessAndChroot(subAgent, sessionId, sessionOver, "agent user"); + cleanupUserProcessAndChroot(subAgent, OpenShellUserSandboxes.ownerForSession(subAgent, null), sessionId, + sessionOver, "agent user"); } // if cloud sync enabled, push and clear the rooms @@ -249,23 +252,25 @@ public Void call() throws Exception { /** * Cleans up user-owned client process and optional chroot resources. * - * @param user the user to clean up - * @param sessionId the session that ended - * @param over true for a logout or timeout, false when the server is stopping - * @param userType descriptive label used in logs (for example, session user) + * @param user the user to clean up + * @param openshellOwner owner of the user's OpenShell sandbox, or null outside that mode + * @param sessionId the session that ended + * @param over true for a logout or timeout, false when the server is stopping + * @param userType descriptive label used in logs (for example, session user) */ - private void cleanupUserProcessAndChroot(User user, String sessionId, boolean over, String userType) { + private void cleanupUserProcessAndChroot(User user, String openshellOwner, String sessionId, boolean over, + String userType) { if (user == null) { return; } - boolean openshellUserSandbox = OpenShellUserSandboxes.appliesTo(user); + boolean openshellUserSandbox = openshellOwner != null; try { if (openshellUserSandbox) { // The python worker and chroot belong to the user, not this session: they // go when the user's last session here ends, and the sandbox when their // last session anywhere does. - OpenShellUserSandboxes.sessionEnded(user, sessionId, over); + OpenShellUserSandboxes.sessionEnded(openshellOwner, sessionId, over); } else { // stop the netty thread if used for either r or python ClientProcessWrapper cpw = user.getPythonClientProcessWrapper();