I have an rprofile that is used for multiple users spinning up sessions in a shared jupyterhub. In it I define a function that sets some parameters for closing out idle sessions (basically it periodically checks when the user activity was and if it was last a threshold, it kills the session). But, if a user clears their workspace/environment, it deletes that function and no longer has it running. Can I get around this somehow? I’ve tried:
defining it in a separate env in the rprofile including it in a finalizer in the r profile (but that only runs on shutdown, not clearing environment/workspace) using gdata::keep in the r profile telling people not to clear their environment (jk but kinda not, obviously impractical) Any suggestions? For reference if it helps, this is the code I need to persist/keep running:
# track last activity
update_activity <- function(expr, value, ok, visible) {
assign(".last_activity", Sys.time(), envir = .GlobalEnv)
#cat(sprintf("[watchdog] Updated at %s\n", Sys.time()))
return(TRUE)
}
addTaskCallback(update_activity, name = "update_activity")
# check how long since last activity
check_idle <- function(timeout_minutes = 1) { # minutes
now <- Sys.time()
idle_time <- as.numeric(difftime(now, .GlobalEnv$.last_activity, units = "mins"))
#cat(sprintf("[watchdog] Idle for %.2f minutes\n", idle_time))
if (idle_time > timeout_minutes) {
cat("Session has been idle too long — exiting.\n")
.Last()
q("no")
} else {
later::later(function() {
check_idle(timeout_minutes)
}, delay = 60) # note - seconds!
}
}
later::later(function() {
check_idle(180) # minutes
}, delay = 60) # seconds
Edit: changed from using rlang::env_unlock()
(which is deprecated) to something more local.
Somes notes about this:
search()
shows it clearly)cat(..)
messages for local validation.Last()
, since on testing my session complained about could not find function ".Last"
... and since q()
will call this function anyway, I think it duplicative.no_idle <- new.env()
.no_idle$.update_activity <- function(expr, value, ok, visible) {
assign(".last_activity", Sys.time(), envir = as.environment(".no_idle"))
cat(sprintf("[watchdog] Updated at %s\n", Sys.time()))
return(TRUE)
}
.no_idle$.last_activity <- Sys.time()
.no_idle$.check_idle <- function(timeout_minutes = 1) { # minutes
env <- as.environment(".no_idle")
now <- Sys.time()
idle_time <- as.numeric(difftime(now, env$.last_activity, units = "mins"))
cat(sprintf("[watchdog] Idle for %.2f minutes\n", idle_time))
if (idle_time > timeout_minutes) {
cat("Session has been idle too long — exiting.\n")
q("no")
} else {
cat("Session is still being used.\n")
}
later::later(function() {
env$.check_idle(timeout_minutes)
}, delay = 15) # note - seconds!
}
It worked:
attach(.no_idle)
rm(.no_idle)
search()
# [1] ".GlobalEnv" ".no_idle" "ESSR" "package:stats" "package:graphics" "package:grDevices" "package:utils"
# [8] "package:datasets" "package:r2" "package:methods" "Autoloads" "package:base"
addTaskCallback(as.environment(".no_idle")$.update_activity, name = "update_activity")
# update_activity
# 1
# [watchdog] Updated at 2025-06-02 20:19:09.435791
later::later(as.environment(".no_idle")$.check_idle(), delay = 15)
# [watchdog] Idle for 0.00 minutes
# Session is still being used.
# [watchdog] Updated at 2025-06-02 20:19:09.505805
ls(all.names = TRUE)
# [1] ".Random.seed"
# [watchdog] Updated at 2025-06-02 20:19:17.225449
# [watchdog] Idle for 0.12 minutes
# Session is still being used.
# [watchdog] Idle for 0.37 minutes
# Session is still being used.
# [watchdog] Idle for 0.62 minutes
# Session is still being used.
1
# [1] 1
# [watchdog] Updated at 2025-06-02 20:20:12.52559
# [watchdog] Idle for 0.14 minutes
# Session is still being used.
# [watchdog] Idle for 0.39 minutes
# Session is still being used.
# [watchdog] Idle for 0.64 minutes
# Session is still being used.
# [watchdog] Idle for 0.89 minutes
# Session is still being used.
# [watchdog] Idle for 1.14 minutes
# Session has been idle too long — exiting.
# Process R:2 finished at Mon Jun 2 20:21:24 2025