Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
93c5140
docs: design spec for interaction-based VS Code idle culling
tylerpotts Aug 19, 2026
ac3ff5a
docs: implementation plan for VS Code idle culling
tylerpotts Aug 19, 2026
c6a9ced
feat: bump code-server to 4.133.0 for idle-timeout support
tylerpotts Aug 19, 2026
699b024
feat: derive CODE_SERVER_IDLE_TIMEOUT_SECONDS from cull.timeout
tylerpotts Aug 19, 2026
64573eb
feat: add vscodeActivity.enabled value with chart-derived plumbing
tylerpotts Aug 19, 2026
bcc3a75
feat: escape hatch env var for vscode proxy activity counting
tylerpotts Aug 19, 2026
cb1ce5b
feat: own the vscode proxy entry with update_last_activity=False
tylerpotts Aug 19, 2026
14aeb27
feat: nebari-activity-reporter VS Code extension
tylerpotts Aug 19, 2026
da331d2
fix: harden activity-reporter URL normalization and ping error handling
tylerpotts Aug 19, 2026
2182330
feat: stdlib vsix packager for image builds
tylerpotts Aug 19, 2026
ceed6bb
fix: xml-escape vsix manifest fields
tylerpotts Aug 19, 2026
488e583
feat: bake nebari-activity-reporter vsix into singleuser image
tylerpotts Aug 19, 2026
2561031
feat: install activity-reporter extension via postStart hook
tylerpotts Aug 19, 2026
12d7f68
test: e2e coverage for VS Code idle-culling plumbing
tylerpotts Aug 19, 2026
7540639
docs: VS Code idle culling behavior and escape hatches
tylerpotts Aug 19, 2026
f67324b
fix(vscode-idle-culling): flip escape-hatch polarity and harden cull …
tylerpotts Aug 19, 2026
7258d3b
ci: point e2e at this branch's built image (sha-7a07d8e)
tylerpotts Aug 19, 2026
d0383cb
fix(e2e): wait for the singleuser server before curling it
tylerpotts Aug 19, 2026
950077d
docs: drop superpowers working docs from the PR
tylerpotts Aug 20, 2026
ed0d261
docs: restore pre-existing umask spec removed by mistake
tylerpotts Aug 20, 2026
f656491
fix: address review feedback on culler keying and install shared fate
tylerpotts Aug 21, 2026
43cae85
chore: bump image tags to sha-ce941be for e2e and soak
tylerpotts Aug 21, 2026
5994a32
Merge branch 'main' into fix/vscode-idle-culling
tylerpotts Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions config/jupyterhub/01-spawner.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,41 @@ def _setup_trust_bundle(spawner):
if _hub_external_host:
env["NEBI_SERVER_ALLOWED_ORIGINS"] = f"https://{_hub_external_host}"

# code-server >= 4.106 exits this many seconds after its last browser
# connection closes. Keyed to singleuserCuller.server.shutdownNoActivityTimeout
# (via _CHART_DERIVED), NOT the hub-level cull.timeout: the hub culler never
# fires while a tab is connected (CHP counts websocket data as route
# activity), so the in-pod timeout is the schedule idle pods actually cull
# on, and a lingering code-server child (tab closed, laptop asleep) should
# die on that same clock (issue #208). Values <= 60 are rejected by
# code-server at startup, so skip them rather than break every pod's VS
# Code; 0 (in-pod culling disabled) lands there too, coherently disabling
# this timer. The value is deployer-supplied and may not parse as an int
# (bad YAML, a stray string); guard the conversion so a bad value just
# disables this feature instead of raising and taking down the whole
# spawner config file.
try:
_vscode_idle_timeout = int(
get_chart_config("shutdown-no-activity-timeout", 0) or 0
)
except (TypeError, ValueError):
_vscode_idle_timeout = 0
if _vscode_idle_timeout > 60:
env["CODE_SERVER_IDLE_TIMEOUT_SECONDS"] = str(_vscode_idle_timeout)

# Escape hatch for the interaction-based VS Code idle culling (issue #208).
# The image defaults to the OLD behavior (counting raw proxied traffic as
# activity) when this env var is absent, so chart/image skew fails safe
# (pods over-spend, rather than culling active users with no reporter
# installed). When vscodeActivity.enabled is true (the chart default),
# actively opt the pod into the new behavior by setting the env var to
# "false"; the image's jupyter_server_config.py reads it when building
# c.ServerProxy.servers["vscode"]. When the deployer sets
# vscodeActivity.enabled=false, set nothing so the image's fail-safe
# default (True) applies.
if get_chart_config("vscode-activity-enabled", True):
env["VSCODE_PROXY_UPDATE_LAST_ACTIVITY"] = "false"

c.KubeSpawner.environment = env


Expand Down Expand Up @@ -1003,6 +1038,27 @@ async def _setup_nss_wrapper(spawner, username, groups):
+ " > /tmp/group",
]

# Install the bundled VS Code activity-reporter extension (issue #208)
# into the user's PVC-backed extensions dir. Runs every spawn:
# idempotent, and --force re-installs on image upgrades (new vsix
# version). Wrapped in { ... || true; } so an install failure neither
# CrashLoops the pod nor (via `&&`/`||` left-associativity) masks a
# failure of the preceding nss-wrapper commands. Silent breakage is
# covered by the e2e extension-installed test. `timeout 60` bounds a
# hung install (e.g. a wedged extensions-dir mount) so it can't stall
# pod startup; timeout(1) is coreutils, present in the ubuntu base
# image. `${CODE_EXTENSIONSDIR:+--extensions-dir "$CODE_EXTENSIONSDIR"}`
# mirrors the --extensions-dir flag the vscode proxy entry
# (images/nebi/jupyter_server_config.py) passes when CODE_EXTENSIONSDIR
# is set, keeping the install location in sync with where code-server
# actually reads extensions from.
nss_cmds.append(
'{ timeout 60 code-server --install-extension '
'/opt/code-server-extensions/nebari-activity-reporter.vsix '
'${CODE_EXTENSIONSDIR:+--extensions-dir "$CODE_EXTENSIONSDIR"} '
'--force || true; }'
)

# Group membership changes between spawns (gain, lose, swap) are a
# normal operational scenario. The home PVC persists, so the shape
# `~/shared` took on the LAST spawn is still there at the start of
Expand Down
54 changes: 54 additions & 0 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Every derived value can still be overridden explicitly. Values under
| `nebariapp` | Whether/how the `NebariApp` CRD is rendered — routing, auth, landing-page card. See [NebariApp Integration](/nebariapp-integration/) |
| `singleuser` | Egress NetworkPolicy allowing user pods to reach the Nebari gateway |
| `singleuserCuller` | In-pod idle culling for kernels, terminals, and the server itself (separate from the hub-level `jupyterhub.cull`) |
| `vscodeActivity` | Interaction-based idle culling for VS Code; `enabled: false` reverts to counting raw proxied traffic as activity |
| `sharedStorage` | Per-group RWX directories and the transitional in-cluster NFS mode. See [Shared Storage](/shared-storage/) |
| `nebi` | The companion Nebi service — image, external/internal URLs, namespace, release name |
| `rbac.bootstrap` | One-shot Keycloak Job that adds the groups-claim mapper and the shared-mount client role |
Expand Down Expand Up @@ -45,3 +46,56 @@ The dummy authenticator is used by default so any username/password works
without a Keycloak dependency. To test against real OAuth, configure
`jupyterhub.hub.config` per the
[Zero to JupyterHub authentication docs](https://z2jh.jupyter.org/en/stable/administrator/authentication.html).

## VS Code idle culling

An open VS Code tab holds a websocket whose keepalives used to count as
Jupyter activity, so pods with an idle VS Code tab were never culled
([#208](https://github.com/nebari-dev/data-science-pack/issues/208)). The
pack now handles VS Code idleness like notebook idleness:

- **Real interaction counts.** A bundled extension
(`nebari-activity-reporter`, installed automatically on every spawn)
reports typing, scrolling, terminal use, and window focus to the Jupyter
server. A running terminal command also counts as active — same policy
as `cullBusy: false` for kernels — provided the shell has VS Code shell
integration (automatic for bash/zsh; exotic shells running long jobs are
not detected).
- **Raw traffic no longer defeats the in-pod culler.** The `/vscode/` proxy
route runs with `update_last_activity` disabled, so keepalives from an
idle tab no longer keep jupyter-server's own activity clock fresh. The
hub-level `jupyterhub.cull` culler is **not** fixed by this: proxied
websocket traffic is still visible to configurable-http-proxy at the
route level, so the hub keeps seeing activity for as long as a tab stays
connected, regardless of `update_last_activity`. The setting that
actually culls an idle-tab pod is the in-pod
`singleuserCuller.server.shutdownNoActivityTimeout` (default `900`
seconds / 15 minutes), which jupyter-server evaluates from its own
activity clock; set it to `0` to disable this feature.
- **Disconnected sessions exit promptly.** `CODE_SERVER_IDLE_TIMEOUT_SECONDS`
is set to `singleuserCuller.server.shutdownNoActivityTimeout` (skipped
when the value is ≤ 60 seconds, which code-server rejects — including
`0`, i.e. in-pod culling disabled), so a code-server process whose last
browser connection has closed exits on the same schedule that culls idle
pods instead of lingering.
- **Delivery failure fails safe.** The proxy-activity opt-out only takes
effect when the reporter extension is actually present in the extensions
directory. If the per-spawn install fails, proxied traffic counts as
activity again — the pod over-spends rather than culling an active user
who has no keep-alive channel.

To revert to the previous behavior (any open tab keeps the pod alive), set:

```yaml
vscodeActivity:
enabled: false
```

For a hard cost cap regardless of activity — e.g. a tab left open on an
always-awake machine — the hub culler's max-age is available separately:

```yaml
jupyterhub:
cull:
maxAge: 86400 # kill servers after 24h no matter what
```
6 changes: 0 additions & 6 deletions images/jupyterlab/pixi.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion images/jupyterlab/pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ gnupg = "*"
pyjwt = ">=2.13.0,<3"

[pypi-dependencies]
jupyter-vscode-proxy = { git = "https://github.com/betatim/vscode-binder.git" }
nb_nebi_kernels = "==0.3"
jupyterlab_nvdashboard = "==0.12.0"
# argo-jupyter-scheduler = "==2024.6.1" # disabled until service is configured
Expand Down
8 changes: 8 additions & 0 deletions images/jupyterlab/postBuild
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,12 @@ set -x
# install code-server extension
sh /opt/scripts/install-code-server.sh "/opt/jupyterlab/.pixi/envs/${DEFAULT_ENV}/share"

# Package the activity-reporter extension as a vsix. Installed per-user by a
# postStart hook (01-spawner.py) because the extensions dir lives on the
# home PVC, which would shadow a build-time install. The pixi env's python
# is not on PATH at this point in the build — use it explicitly.
"/opt/jupyterlab/.pixi/envs/${DEFAULT_ENV}/bin/python" /opt/scripts/build-vsix.py \
/opt/jupyterlab/vscode-activity-reporter \
/opt/code-server-extensions/nebari-activity-reporter.vsix

fix-permissions "/opt/jupyterlab/.pixi/envs/${DEFAULT_ENV}"
123 changes: 123 additions & 0 deletions images/jupyterlab/vscode-activity-reporter/extension.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"use strict";
// Reports real user interaction to the local Jupyter server.
//
// The vscode proxy route runs with update_last_activity=False (see
// images/nebi/jupyter_server_config.py), so VS Code traffic no longer
// counts as jupyter activity. Without this extension, actively working
// VS Code users would be idle-culled — this is the load-bearing half of
// nebari-dev/data-science-pack#208.
//
// Endpoint choice: /api/status and /api/ set _track_activity=False
// upstream (so pollers don't defeat culling); /api/contents/ is tracked.
const vscode = require("vscode");
const http = require("http");

const PING_INTERVAL_MS = 60 * 1000;

let lastPingMs = 0;
let busyExecutions = 0; // in-flight terminal shell executions
let output;

function pingUrl() {
let base = process.env.JUPYTERHUB_SERVICE_URL;
if (!base) {
return null; // not running under JupyterHub — nothing to report to
}
try {
// Normalize IPv6 any-host (::) to bracketed form before URL parsing
base = base.replace("://:", "://[::]");
const url = new URL(base);
if (url.hostname === "0.0.0.0" || url.hostname === "[::]") {
url.hostname = "127.0.0.1";
}
url.pathname = url.pathname.replace(/\/?$/, "/") + "api/contents/";
url.search = "?content=0";
return url;
} catch (e) {
output.appendLine(`bad JUPYTERHUB_SERVICE_URL: ${e.message}`);
return null;
}
}

function ping(reason) {
const url = pingUrl();
const token = process.env.JUPYTERHUB_API_TOKEN;
if (!url || !token) {
return;
}
try {
const req = http.get(
url,
{ headers: { Authorization: `token ${token}` } },
(res) => {
res.resume(); // drain — only the request itself matters
if (res.statusCode < 200 || res.statusCode >= 300) {
output.appendLine(`activity ping (${reason}): HTTP ${res.statusCode}`);
}
},
);
req.on("error", (e) => {
// Never throw out of an event handler; next interaction retries.
output.appendLine(`activity ping (${reason}) failed: ${e.message}`);
});
} catch (e) {
output.appendLine(`activity ping (${reason}) failed: ${e.message}`);
}
}

function recordActivity(reason) {
const now = Date.now();
if (now - lastPingMs < PING_INTERVAL_MS) {
return;
}
lastPingMs = now;
ping(reason);
}

function activate(context) {
output = vscode.window.createOutputChannel("Nebari Activity Reporter");
output.appendLine("activated");

const on = (event, reason) => {
context.subscriptions.push(event(() => recordActivity(reason)));
};
on(vscode.workspace.onDidChangeTextDocument, "edit");
on(vscode.window.onDidChangeTextEditorSelection, "selection");
on(vscode.window.onDidChangeTextEditorVisibleRanges, "scroll");
on(vscode.window.onDidChangeWindowState, "focus");
on(vscode.window.onDidOpenTerminal, "terminal-open");
on(vscode.window.onDidCloseTerminal, "terminal-close");

// Busy = active: a running terminal command keeps the pod alive, like
// cullBusy=false does for kernels. Requires shell integration (auto-
// injected for bash/zsh). Guarded: API is stable since 1.93 but cheap
// to feature-detect.
if (vscode.window.onDidStartTerminalShellExecution) {
context.subscriptions.push(
vscode.window.onDidStartTerminalShellExecution(() => {
busyExecutions += 1;
recordActivity("exec-start");
}),
);
context.subscriptions.push(
vscode.window.onDidEndTerminalShellExecution(() => {
busyExecutions = Math.max(0, busyExecutions - 1);
recordActivity("exec-end");
}),
);
}

const busyTimer = setInterval(() => {
if (busyExecutions > 0) {
recordActivity("busy");
}
}, PING_INTERVAL_MS);
context.subscriptions.push({ dispose: () => clearInterval(busyTimer) });

// A user just opened/reconnected VS Code — that is activity.
recordActivity("startup");
}

function deactivate() {}

module.exports = { activate, deactivate };
13 changes: 13 additions & 0 deletions images/jupyterlab/vscode-activity-reporter/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "nebari-activity-reporter",
"displayName": "Nebari Activity Reporter",
"description": "Reports real user interaction to the Jupyter server so idle culling works correctly with VS Code (nebari-dev/data-science-pack#208).",
"publisher": "nebari",
"version": "0.1.0",
"license": "BSD-3-Clause",
"engines": { "vscode": "^1.93.0" },
"categories": ["Other"],
"activationEvents": ["onStartupFinished"],
"main": "./extension.js",
"contributes": {}
}
9 changes: 9 additions & 0 deletions images/nebi/icons/code-server.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading