diff --git a/config/jupyterhub/01-spawner.py b/config/jupyterhub/01-spawner.py
index 917ee4b..56faa12 100644
--- a/config/jupyterhub/01-spawner.py
+++ b/config/jupyterhub/01-spawner.py
@@ -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
@@ -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
diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md
index f25dd47..4d1fad9 100644
--- a/docs/src/content/docs/configuration.md
+++ b/docs/src/content/docs/configuration.md
@@ -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 |
@@ -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
+```
diff --git a/images/jupyterlab/pixi.lock b/images/jupyterlab/pixi.lock
index 362926a..4f4e481 100644
--- a/images/jupyterlab/pixi.lock
+++ b/images/jupyterlab/pixi.lock
@@ -430,7 +430,6 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/noarch/xyzservices-2025.1.0-pyhd8ed1ab_0.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/zict-3.0.0-pyhd8ed1ab_1.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.21.0-pyhd8ed1ab_1.conda
- - pypi: git+https://github.com/betatim/vscode-binder.git#3e69a137988539f24381e07dd373a1a372098ff4
- pypi: https://files.pythonhosted.org/packages/02/11/9cae49425dbb3e89a7bb3a4d2e974711ad86baa6cc1f47c9bb4e009adced/jupyterlab_jhub_apps-0.3.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/06/ad/5ba858ffc307ed2b51693d87642f9e9dd6013579840981ac364c6e24c38c/backports_zstd-1.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
@@ -909,7 +908,6 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/noarch/xyzservices-2025.1.0-pyhd8ed1ab_0.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/zict-3.0.0-pyhd8ed1ab_1.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.21.0-pyhd8ed1ab_1.conda
- - pypi: git+https://github.com/betatim/vscode-binder.git#3e69a137988539f24381e07dd373a1a372098ff4
- pypi: https://files.pythonhosted.org/packages/02/11/9cae49425dbb3e89a7bb3a4d2e974711ad86baa6cc1f47c9bb4e009adced/jupyterlab_jhub_apps-0.3.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/02/57/7163ed06a2d9bf1f34d89dcc7c5881119beeed287022c997b0a706edcfbe/dulwich-0.22.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- pypi: https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl
@@ -9107,10 +9105,6 @@ packages:
- pkg:pypi/zipp?source=hash-mapping
size: 21809
timestamp: 1732827613585
-- pypi: git+https://github.com/betatim/vscode-binder.git#3e69a137988539f24381e07dd373a1a372098ff4
- name: jupyter-vscode-proxy
- version: 0.7.post4+g3e69a1379
- requires_python: '>=3.8'
- pypi: https://files.pythonhosted.org/packages/02/11/9cae49425dbb3e89a7bb3a4d2e974711ad86baa6cc1f47c9bb4e009adced/jupyterlab_jhub_apps-0.3.1-py3-none-any.whl
name: jupyterlab-jhub-apps
version: 0.3.1
diff --git a/images/jupyterlab/pixi.toml b/images/jupyterlab/pixi.toml
index 65f9719..f1fce60 100644
--- a/images/jupyterlab/pixi.toml
+++ b/images/jupyterlab/pixi.toml
@@ -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
diff --git a/images/jupyterlab/postBuild b/images/jupyterlab/postBuild
index 76c087e..11730b1 100755
--- a/images/jupyterlab/postBuild
+++ b/images/jupyterlab/postBuild
@@ -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}"
diff --git a/images/jupyterlab/vscode-activity-reporter/extension.js b/images/jupyterlab/vscode-activity-reporter/extension.js
new file mode 100644
index 0000000..544485a
--- /dev/null
+++ b/images/jupyterlab/vscode-activity-reporter/extension.js
@@ -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 };
diff --git a/images/jupyterlab/vscode-activity-reporter/package.json b/images/jupyterlab/vscode-activity-reporter/package.json
new file mode 100644
index 0000000..94ef9a9
--- /dev/null
+++ b/images/jupyterlab/vscode-activity-reporter/package.json
@@ -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": {}
+}
diff --git a/images/nebi/icons/code-server.svg b/images/nebi/icons/code-server.svg
new file mode 100644
index 0000000..85b701d
--- /dev/null
+++ b/images/nebi/icons/code-server.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/images/nebi/jupyter_server_config.py b/images/nebi/jupyter_server_config.py
index e37dcd2..c0d91c2 100644
--- a/images/nebi/jupyter_server_config.py
+++ b/images/nebi/jupyter_server_config.py
@@ -1,3 +1,4 @@
+import glob
import mimetypes
import os
import shutil
@@ -80,3 +81,81 @@
},
}
}
+
+# jupyter-server-proxy configuration for VS Code (code-server).
+# Registered here instead of via the jupyter-vscode-proxy package so the
+# entry can set update_last_activity=False: with it True (the packaged
+# default), the VS Code browser client's websocket keepalives count as
+# jupyter API activity, and the in-pod shutdown_no_activity_timeout — the
+# mechanism that actually culls idle-tab pods — never fires while a tab is
+# open (https://github.com/nebari-dev/data-science-pack/issues/208). (The
+# hub-level idle culler is defeated separately and regardless of this
+# setting: configurable-http-proxy counts websocket data as route
+# activity.) Real user interaction is reported instead by the bundled
+# nebari-activity-reporter extension (see
+# images/jupyterlab/vscode-activity-reporter/).
+VSCODE_ICON_PATH = os.path.join(
+ os.path.dirname(os.path.abspath(__file__)),
+ "icons",
+ "code-server.svg",
+)
+
+
+def _vscode_command():
+ # Mirrors the command jupyter-vscode-proxy generated (minus unix-socket
+ # support, which nothing here used). {port} is templated by
+ # jupyter-server-proxy at launch.
+ cmd = ["code-server", "--auth", "none", "--disable-telemetry", "--port={port}"]
+ extensions_dir = os.environ.get("CODE_EXTENSIONSDIR")
+ if extensions_dir:
+ cmd += ["--extensions-dir", extensions_dir]
+ cmd.append(os.environ.get("CODE_WORKINGDIR", "."))
+ return cmd
+
+
+def _vscode_reporter_installed():
+ # Shared fate with the keep-alive channel: the chart env var (set
+ # reliably by the spawner) and the reporter vsix install (per-pod
+ # postStart under `|| true`) live in separate failure domains, so a
+ # pod can otherwise land with proxy activity disabled AND no reporter
+ # — and cull an actively-working user at shutdown_no_activity_timeout.
+ # Gate on the installed artifact so a failed install degrades to
+ # over-spending (proxied traffic counts as activity again) instead.
+ # Caveat: postStart runs concurrently with the container entrypoint,
+ # so on a user's first-ever spawn the install may not have finished
+ # when this file is evaluated; that session over-spends, and the
+ # PVC-backed extensions dir makes every later spawn see the artifact.
+ ext_dir = os.environ.get("CODE_EXTENSIONSDIR") or os.path.expanduser(
+ "~/.local/share/code-server/extensions"
+ )
+ return bool(
+ glob.glob(os.path.join(ext_dir, "nebari.nebari-activity-reporter-*"))
+ )
+
+
+# Fail-safe default: absent/empty means the OLD behavior (count proxied
+# traffic as activity) applies. The chart actively opts pods into the new
+# interaction-based behavior by setting this env var to "false" when
+# vscodeActivity.enabled is true (config/jupyterhub/01-spawner.py). This
+# polarity means chart/image skew (e.g. a newer image tag paired with an
+# older chart release that doesn't yet set the env var) degrades to the
+# safe failure mode (pods over-spend on proxied traffic staying "active")
+# rather than the dangerous one (culling active VS Code users who have no
+# activity-reporter extension installed to keep them alive). The same
+# polarity covers per-pod install failure via _vscode_reporter_installed().
+_value = os.environ.get("VSCODE_PROXY_UPDATE_LAST_ACTIVITY", "").strip().lower()
+_vscode_count_proxy_traffic = (
+ _value not in ("0", "false", "no") or not _vscode_reporter_installed()
+)
+
+c.ServerProxy.servers["vscode"] = {
+ "command": _vscode_command(),
+ "timeout": 300,
+ "new_browser_tab": True,
+ "update_last_activity": _vscode_count_proxy_traffic,
+ "launcher_entry": {
+ "title": "VS Code",
+ "enabled": True,
+ "icon_path": VSCODE_ICON_PATH,
+ },
+}
diff --git a/images/scripts/build-vsix.py b/images/scripts/build-vsix.py
new file mode 100644
index 0000000..aa53988
--- /dev/null
+++ b/images/scripts/build-vsix.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+# Copyright (c) Nebari Development Team.
+# Distributed under the terms of the Modified BSD License.
+"""Package a VS Code extension directory as a .vsix, without vsce.
+
+A .vsix is a zip containing extension.vsixmanifest, [Content_Types].xml
+and the extension files under extension/. `code-server
+--install-extension` reads the manifest Identity plus
+extension/package.json. Used at image build time because the build
+environment has no node/npm toolchain.
+
+Usage: build-vsix.py
+"""
+
+import json
+import sys
+import zipfile
+from pathlib import Path
+from xml.sax.saxutils import escape
+
+MANIFEST = """
+
+
+
+ {display}
+ {description}
+ Other
+
+
+
+
+
+
+
+
+
+"""
+
+CONTENT_TYPES = """
+
+
+
+
+
+"""
+
+
+def main(src, dest):
+ src, dest = Path(src), Path(dest)
+ pkg = json.loads((src / "package.json").read_text())
+ # Escape XML special characters for both attributes and element text
+ # For attributes, also escape quotes
+ manifest = MANIFEST.format(
+ name=escape(pkg["name"], {'"': """}),
+ version=escape(pkg["version"], {'"': """}),
+ publisher=escape(pkg["publisher"], {'"': """}),
+ display=escape(pkg.get("displayName", pkg["name"])),
+ description=escape(pkg.get("description", "")),
+ )
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z:
+ z.writestr("extension.vsixmanifest", manifest)
+ z.writestr("[Content_Types].xml", CONTENT_TYPES)
+ for f in sorted(src.rglob("*")):
+ if f.is_file():
+ z.write(f, "extension/" + str(f.relative_to(src)))
+
+
+if __name__ == "__main__":
+ if len(sys.argv) != 3:
+ sys.exit(__doc__)
+ main(sys.argv[1], sys.argv[2])
diff --git a/images/scripts/install-code-server.sh b/images/scripts/install-code-server.sh
index bbe9733..a354cd3 100755
--- a/images/scripts/install-code-server.sh
+++ b/images/scripts/install-code-server.sh
@@ -5,13 +5,13 @@
set -xe
DEFAULT_PREFIX="${1}"
shift # path to environment yaml or lock file
-CODE_SERVER_VERSION=4.104.3
+CODE_SERVER_VERSION=4.133.0
mkdir -p ${DEFAULT_PREFIX}/code-server
cd ${DEFAULT_PREFIX}/code-server
# Fetch the snapshot of https://code-server.dev/install.sh as of the time of writing
-wget --quiet https://raw.githubusercontent.com/coder/code-server/v4.104.3/install.sh
+wget --quiet https://raw.githubusercontent.com/coder/code-server/v4.133.0/install.sh
expected_sum=e86784e9fec81106c74941e55dbbcb85dc963a06ad6c3f1a870d4a22cf432e1d
if [[ ! $(sha256sum install.sh) == "${expected_sum} install.sh" ]]; then
diff --git a/templates/hub-config.yaml b/templates/hub-config.yaml
index 7a3ebc2..3624f95 100644
--- a/templates/hub-config.yaml
+++ b/templates/hub-config.yaml
@@ -54,6 +54,13 @@ data:
# both. Deployer override via jupyterhub.custom.shared-storage-enabled
# still wins via z2jh.
"shared-storage-enabled": {{ ternary "True" "False" .Values.sharedStorage.enabled }},
+ # Mirrors .Values.vscodeActivity.enabled — drives the vscode proxy
+ # route's update_last_activity escape hatch in 01-spawner.py.
+ "vscode-activity-enabled": {{ ternary "True" "False" .Values.vscodeActivity.enabled }},
+ # Drives CODE_SERVER_IDLE_TIMEOUT_SECONDS in 01-spawner.py: the
+ # code-server exit timer mirrors the in-pod culler's schedule, the
+ # one that actually culls idle pods (issue #208).
+ "shutdown-no-activity-timeout": {{ .Values.singleuserCuller.server.shutdownNoActivityTimeout | quote }},
}
diff --git a/tests/e2e/test_vscode_idle_culling.py b/tests/e2e/test_vscode_idle_culling.py
new file mode 100644
index 0000000..4d888a2
--- /dev/null
+++ b/tests/e2e/test_vscode_idle_culling.py
@@ -0,0 +1,135 @@
+"""VS Code idle-culling behavior (issue #208), verified inside a live pod.
+
+What e2e can and cannot cover: these tests exercise the proxy-activity
+plumbing, the extension delivery, and the reporting endpoint — via HTTP
+from inside the pod. Extension *activation* needs a real VS Code browser
+client, which this harness doesn't have; that path is validated by manual
+soak (see the design spec).
+
+These tests curl `127.0.0.1:8888` directly from inside the pod, bypassing
+configurable-http-proxy (CHP) entirely. That means they cannot observe
+CHP-level route activity tracking: the mechanism that keeps the hub-level
+`jupyterhub.cull` culler's last-activity fresh independent of
+`update_last_activity` while a tab stays connected (see the design spec's
+corrected mental model). What they DO verify is the in-pod
+`api_last_activity` signal that `singleuserCuller.server.
+shutdownNoActivityTimeout` actually reads. Also: because the co-installed
+`nebari-activity-reporter` extension never activates without a real VS Code
+client in this harness, it never fires its own contents-API pings during
+these tests, which is exactly why
+`test_vscode_proxy_traffic_does_not_count_as_activity` below can assert a
+stable `last_activity` across repeated proxied requests: nothing else in
+the pod is nudging it forward.
+"""
+
+import json
+import time
+
+import pytest
+
+# JUPYTERHUB_SERVICE_PREFIX ends with "/" — concatenate with ${VAR} (no
+# added slash): the proxy route regex does not tolerate "//vscode/".
+CURL_STATUS = (
+ 'curl -sf -H "Authorization: token $JUPYTERHUB_API_TOKEN" '
+ '"http://127.0.0.1:8888${JUPYTERHUB_SERVICE_PREFIX}api/status"'
+)
+
+
+def _wait_for_server(user, timeout_s=180):
+ """Block until the singleuser jupyter server answers on :8888.
+
+ `spawn_user` waits for the POD Ready condition, but singleuser pods
+ have no readiness probe on the jupyter port, so `kubectl exec` can win
+ the race against `jupyterhub-singleuser` binding :8888 (first observed
+ as curl rc=7 in CI). Poll the status endpoint until it answers; every
+ other exec in these tests can then assume the server is up.
+ """
+ deadline = time.time() + timeout_s
+ rc, out = 1, ""
+ while time.time() < deadline:
+ rc, out = user.exec("bash", "-c", CURL_STATUS)
+ if rc == 0:
+ return
+ time.sleep(3)
+ pytest.fail(
+ f"singleuser server never answered /api/status within {timeout_s}s "
+ f"(last rc={rc}: {out})"
+ )
+
+
+def _last_activity(user):
+ rc, out = user.exec("bash", "-c", CURL_STATUS)
+ assert rc == 0, f"/api/status failed (rc={rc}): {out}"
+ return json.loads(out)["last_activity"]
+
+
+def test_code_server_idle_timeout_env_matches_inpod_culler(spawn_user):
+ """Chart default singleuserCuller.server.shutdownNoActivityTimeout=900
+ must reach the pod env verbatim — the code-server exit timer mirrors
+ the in-pod culler (the schedule idle pods actually cull on), not the
+ hub-level cull.timeout."""
+ u = spawn_user("alice-data")
+ rc, out = u.exec("printenv", "CODE_SERVER_IDLE_TIMEOUT_SECONDS")
+ assert rc == 0, "CODE_SERVER_IDLE_TIMEOUT_SECONDS not set on the pod"
+ assert out.strip() == "900"
+
+
+def test_activity_reporter_extension_installed(spawn_user):
+ """postStart must install the bundled vsix. This is the tripwire for
+ the worst failure mode: silently-broken delivery would get active VS
+ Code users culled mid-session."""
+ u = spawn_user("alice-data")
+ rc, out = u.exec(
+ "bash", "-c", "ls /home/jovyan/.local/share/code-server/extensions/"
+ )
+ assert rc == 0, out
+ assert "nebari.nebari-activity-reporter" in out
+
+
+def test_vscode_proxy_traffic_does_not_count_as_activity(spawn_user):
+ """The core #208 behavior: requests through /vscode/ (which is exactly
+ what an open tab's keepalives are) must NOT advance last_activity."""
+ u = spawn_user("alice-data")
+ _wait_for_server(u)
+ # First hit starts code-server via jupyter-server-proxy (timeout 300 in
+ # the server entry; jsp blocks the request until the backend is up).
+ rc, out = u.exec(
+ "bash", "-c",
+ 'curl -sf -o /dev/null -H "Authorization: token $JUPYTERHUB_API_TOKEN" '
+ '"http://127.0.0.1:8888${JUPYTERHUB_SERVICE_PREFIX}vscode/"',
+ )
+ assert rc == 0, f"vscode proxy route failed to start code-server: {out}"
+
+ before = _last_activity(u)
+ for _ in range(3):
+ time.sleep(2)
+ u.exec(
+ "bash", "-c",
+ 'curl -s -o /dev/null -H "Authorization: token $JUPYTERHUB_API_TOKEN" '
+ '"http://127.0.0.1:8888${JUPYTERHUB_SERVICE_PREFIX}vscode/"',
+ )
+ after = _last_activity(u)
+ assert after == before, (
+ f"proxied vscode traffic advanced last_activity {before} -> {after}; "
+ "update_last_activity=False is not applied on the vscode entry"
+ )
+
+
+def test_contents_api_ping_counts_as_activity(spawn_user):
+ """The extension's reporting mechanism: an authenticated contents-API
+ request must advance last_activity (ISO8601 compares lexicographically)."""
+ u = spawn_user("alice-data")
+ _wait_for_server(u)
+ before = _last_activity(u)
+ time.sleep(1.1) # ensure a strictly later timestamp is observable
+ rc, out = u.exec(
+ "bash", "-c",
+ 'curl -sf -o /dev/null -H "Authorization: token $JUPYTERHUB_API_TOKEN" '
+ '"http://127.0.0.1:8888${JUPYTERHUB_SERVICE_PREFIX}api/contents/?content=0"',
+ )
+ assert rc == 0, out
+ after = _last_activity(u)
+ assert after > before, (
+ "contents-API ping did not advance last_activity — the extension's "
+ "reporting endpoint would be ineffective"
+ )
diff --git a/tests/unit/test_build_vsix.py b/tests/unit/test_build_vsix.py
new file mode 100644
index 0000000..f9378f3
--- /dev/null
+++ b/tests/unit/test_build_vsix.py
@@ -0,0 +1,85 @@
+"""build-vsix.py packages an extension dir into a code-server-installable
+.vsix (zip with manifest) without needing node/vsce in the image build."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import xml.etree.ElementTree
+import zipfile
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+SCRIPT = REPO_ROOT / "images" / "scripts" / "build-vsix.py"
+
+
+def _build(tmp_path: Path) -> Path:
+ src = tmp_path / "ext"
+ src.mkdir()
+ (src / "package.json").write_text(json.dumps({
+ "name": "nebari-activity-reporter",
+ "publisher": "nebari",
+ "version": "0.1.0",
+ "displayName": "Nebari Activity Reporter",
+ "description": "test fixture",
+ }))
+ (src / "extension.js").write_text("module.exports = {};\n")
+ dest = tmp_path / "out" / "ext.vsix"
+
+ spec = importlib.util.spec_from_file_location("_build_vsix", SCRIPT)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ module.main(str(src), str(dest))
+ return dest
+
+
+def test_vsix_layout(tmp_path):
+ dest = _build(tmp_path)
+ with zipfile.ZipFile(dest) as z:
+ names = set(z.namelist())
+ assert "extension.vsixmanifest" in names
+ assert "[Content_Types].xml" in names
+ assert "extension/package.json" in names
+ assert "extension/extension.js" in names
+
+
+def test_vsix_manifest_identity(tmp_path):
+ dest = _build(tmp_path)
+ with zipfile.ZipFile(dest) as z:
+ manifest = z.read("extension.vsixmanifest").decode()
+ assert 'Id="nebari-activity-reporter"' in manifest
+ assert 'Publisher="nebari"' in manifest
+ assert 'Version="0.1.0"' in manifest
+
+
+def test_vsix_manifest_xml_escaping(tmp_path):
+ """Manifest field values with &, <, > and " must be XML-escaped."""
+ src = tmp_path / "ext"
+ src.mkdir()
+ (src / "package.json").write_text(json.dumps({
+ "name": "test-extension",
+ "publisher": "Foo & \"baz\"",
+ "version": "0.1.0",
+ "displayName": "Test & \"Extension\"",
+ "description": "Foo & \"baz\"",
+ }))
+ (src / "extension.js").write_text("module.exports = {};\n")
+ dest = tmp_path / "out" / "ext.vsix"
+
+ spec = importlib.util.spec_from_file_location("_build_vsix", SCRIPT)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ module.main(str(src), str(dest))
+
+ with zipfile.ZipFile(dest) as z:
+ manifest = z.read("extension.vsixmanifest").decode()
+
+ # Verify escaped forms are present
+ # Attributes must escape &, <, >, and "
+ assert 'Publisher="Foo & <Bar> "baz""' in manifest
+ # Element text must escape &, <, > but not "
+ assert 'Test & <Unsafe> "Extension"' in manifest
+ assert 'Foo & <Bar> "baz"' in manifest
+
+ # Verify manifest is valid XML
+ xml.etree.ElementTree.fromstring(manifest)
diff --git a/tests/unit/test_chart_derived.py b/tests/unit/test_chart_derived.py
index fe94183..0111621 100644
--- a/tests/unit/test_chart_derived.py
+++ b/tests/unit/test_chart_derived.py
@@ -148,3 +148,21 @@ def test_get_chart_config_explicit_override_wins(rendered_chart_derived):
)
got = ns["get_chart_config"]("external-url")
assert got == "explicit.example.com"
+
+
+def test_vscode_activity_enabled_defaults_true(rendered_chart_derived):
+ """`vscodeActivity.enabled` must reach the spawner via _CHART_DERIVED so
+ the update_last_activity escape hatch (issue #208) is wireable without
+ a jupyterhub.custom.* override."""
+ ns = _exec_chart_derived(rendered_chart_derived, z2jh_values={})
+ assert ns["get_chart_config"]("vscode-activity-enabled") is True
+
+
+def test_shutdown_no_activity_timeout_reaches_spawner(rendered_chart_derived):
+ """`singleuserCuller.server.shutdownNoActivityTimeout` drives
+ CODE_SERVER_IDLE_TIMEOUT_SECONDS (issue #208): the code-server exit
+ timer must mirror the in-pod culler's schedule, so the value has to be
+ visible to 01-spawner.py via _CHART_DERIVED (rendered as a quoted
+ string; the spawner int-parses it)."""
+ ns = _exec_chart_derived(rendered_chart_derived, z2jh_values={})
+ assert ns["get_chart_config"]("shutdown-no-activity-timeout") == "900"
diff --git a/tests/unit/test_nss_wrapper_shared_dir.py b/tests/unit/test_nss_wrapper_shared_dir.py
index 26a28e0..9ea0627 100644
--- a/tests/unit/test_nss_wrapper_shared_dir.py
+++ b/tests/unit/test_nss_wrapper_shared_dir.py
@@ -154,3 +154,32 @@ def test_groups_without_shared_storage_preserves_user_data_in_per_group_dirs():
assert "mkdir -p /home/jovyan/shared" in cmd
assert "mkdir -p /home/jovyan/shared/data" in cmd
assert "mkdir -p /home/jovyan/shared/ml" in cmd
+
+
+def test_poststart_installs_activity_reporter_extension_nonfatally():
+ """Every spawn installs the bundled activity-reporter vsix into the
+ PVC-backed extensions dir (idempotent; --force handles upgrades). The
+ braces + `|| true` isolate the install: with a bare `a && b || true`,
+ a failure of the nss-wrapper printf commands would ALSO be masked,
+ and a failed install would CrashLoop the pod without the guard.
+
+ Two additional properties pinned here:
+ * `timeout 60` bounds the install's runtime so a hung
+ `code-server --install-extension` (e.g. a wedged extensions-dir
+ mount) cannot stall pod startup indefinitely.
+ * `${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, so the extension is installed into the
+ SAME directory VS Code itself reads extensions from.
+ """
+ mod = _load_spawner_module(shared_storage_enabled=False)
+ spawner = FakeSpawner()
+ asyncio.run(mod._setup_nss_wrapper(spawner, "alice", []))
+ cmd = _poststart_cmd(spawner)
+ assert (
+ '{ timeout 60 code-server --install-extension '
+ '/opt/code-server-extensions/nebari-activity-reporter.vsix '
+ '${CODE_EXTENSIONSDIR:+--extensions-dir "$CODE_EXTENSIONSDIR"} '
+ '--force || true; }' in cmd
+ )
diff --git a/tests/unit/test_spawner_code_server_idle.py b/tests/unit/test_spawner_code_server_idle.py
new file mode 100644
index 0000000..e5e16aa
--- /dev/null
+++ b/tests/unit/test_spawner_code_server_idle.py
@@ -0,0 +1,118 @@
+"""CODE_SERVER_IDLE_TIMEOUT_SECONDS wiring in `01-spawner.py`.
+
+code-server >= 4.106 exits N seconds after its last browser connection
+closes when this env var is set. It must mirror the in-pod culler's
+`singleuserCuller.server.shutdownNoActivityTimeout` (issue #208) — the
+schedule idle pods actually cull on, since the hub-level culler never
+fires while a tab is connected (CHP counts websocket data as route
+activity). It must be ABSENT when the value is <= 60, because code-server
+refuses to start for values <= 60 and that would take down every user
+pod's VS Code; 0 (in-pod culling disabled) lands there too.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import sys
+import types
+
+# 01-spawner.py imports `z2jh.get_config`; stub it so the module exec's standalone.
+_z2jh = types.ModuleType("z2jh")
+_z2jh.get_config = lambda key, default=None: default
+sys.modules.setdefault("z2jh", _z2jh)
+
+from conftest import CONFIG_DIR, FakeConfig # noqa: E402
+
+
+def _load_spawner(c: FakeConfig, z2jh_values: dict | None = None,
+ chart_values: dict | None = None):
+ """Exec raw 01-spawner.py with per-test z2jh + chart config stubs."""
+ z2jh_values = z2jh_values or {}
+ chart_values = chart_values or {}
+ z2jh = sys.modules["z2jh"]
+ orig = z2jh.get_config
+ z2jh.get_config = lambda key, default=None: z2jh_values.get(key, default)
+ try:
+ path = CONFIG_DIR / "01-spawner.py"
+ spec = importlib.util.spec_from_file_location("_spawner_idle", path)
+ module = importlib.util.module_from_spec(spec)
+ module.__dict__["c"] = c
+ module.__dict__["get_chart_config"] = (
+ lambda key, default="": chart_values.get(key, default)
+ )
+ spec.loader.exec_module(module)
+ finally:
+ z2jh.get_config = orig
+
+
+def test_idle_timeout_matches_shutdown_no_activity_timeout():
+ """_CHART_DERIVED renders the value as a quoted string — it must parse
+ and reach the env verbatim."""
+ c = FakeConfig()
+ _load_spawner(c, chart_values={"shutdown-no-activity-timeout": "900"})
+ assert c.KubeSpawner.environment["CODE_SERVER_IDLE_TIMEOUT_SECONDS"] == "900"
+
+
+def test_idle_timeout_absent_when_inpod_culling_disabled():
+ """shutdownNoActivityTimeout: 0 disables in-pod culling — there is no
+ schedule to mirror, so the code-server timer must be off too."""
+ c = FakeConfig()
+ _load_spawner(c, chart_values={"shutdown-no-activity-timeout": "0"})
+ assert "CODE_SERVER_IDLE_TIMEOUT_SECONDS" not in c.KubeSpawner.environment
+
+
+def test_idle_timeout_independent_of_hub_culler():
+ """Regression for the review-flagged coupling: disabling the hub-level
+ `cull` (which CHP defeats anyway) must NOT turn off the code-server
+ timer — it keys off the in-pod culler alone."""
+ c = FakeConfig()
+ _load_spawner(
+ c,
+ z2jh_values={"cull.enabled": False, "cull.timeout": 1800},
+ chart_values={"shutdown-no-activity-timeout": "900"},
+ )
+ assert c.KubeSpawner.environment["CODE_SERVER_IDLE_TIMEOUT_SECONDS"] == "900"
+
+
+def test_idle_timeout_absent_when_60_or_less():
+ """code-server errors out at startup for values <= 60 — never set them."""
+ c = FakeConfig()
+ _load_spawner(c, chart_values={"shutdown-no-activity-timeout": "60"})
+ assert "CODE_SERVER_IDLE_TIMEOUT_SECONDS" not in c.KubeSpawner.environment
+
+
+def test_idle_timeout_set_at_boundary_61():
+ """61 is the smallest value code-server accepts; must be set verbatim."""
+ c = FakeConfig()
+ _load_spawner(c, chart_values={"shutdown-no-activity-timeout": "61"})
+ assert c.KubeSpawner.environment["CODE_SERVER_IDLE_TIMEOUT_SECONDS"] == "61"
+
+
+def test_idle_timeout_ignores_non_numeric_value():
+ """The value is deployer-supplied and may not parse as an int; a bad
+ value must disable the feature rather than raise and take down the
+ whole spawner config file."""
+ c = FakeConfig()
+ _load_spawner(c, chart_values={"shutdown-no-activity-timeout": "not-a-number"})
+ assert "CODE_SERVER_IDLE_TIMEOUT_SECONDS" not in c.KubeSpawner.environment
+
+
+def test_proxy_activity_env_set_false_by_default():
+ """Default (vscodeActivity.enabled=true): the chart actively opts the
+ pod into the new interaction-based behavior by setting the env var to
+ "false". The image defaults to the OLD behavior (True) when the var is
+ absent, so this active opt-in is what the chart is responsible for."""
+ c = FakeConfig()
+ _load_spawner(c, chart_values={"vscode-activity-enabled": True})
+ assert (
+ c.KubeSpawner.environment["VSCODE_PROXY_UPDATE_LAST_ACTIVITY"] == "false"
+ )
+
+
+def test_proxy_activity_env_absent_when_vscode_activity_disabled():
+ """vscodeActivity.enabled=false is the field escape hatch: the chart
+ sets nothing, so the image's fail-safe default (True, pre-#208
+ behavior, proxied traffic counts as activity again) applies."""
+ c = FakeConfig()
+ _load_spawner(c, chart_values={"vscode-activity-enabled": False})
+ assert "VSCODE_PROXY_UPDATE_LAST_ACTIVITY" not in c.KubeSpawner.environment
diff --git a/tests/unit/test_vscode_server_registration.py b/tests/unit/test_vscode_server_registration.py
new file mode 100644
index 0000000..4734638
--- /dev/null
+++ b/tests/unit/test_vscode_server_registration.py
@@ -0,0 +1,140 @@
+"""The image-owned jupyter_server_config.py must register the `vscode`
+jupyter-server-proxy entry itself (the jupyter-vscode-proxy package was
+dropped) with update_last_activity=False, so VS Code keepalive traffic
+stops defeating the idle cullers (issue #208).
+
+This file exec's like JupyterHub does: with `c` in scope.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+from pathlib import Path
+
+from conftest import FakeConfig
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+IMAGE_CONFIG = REPO_ROOT / "images" / "nebi" / "jupyter_server_config.py"
+
+
+def _load_image_config(c: FakeConfig):
+ spec = importlib.util.spec_from_file_location("_img_jsc", IMAGE_CONFIG)
+ module = importlib.util.module_from_spec(spec)
+ module.__dict__["c"] = c
+ spec.loader.exec_module(module)
+ return module
+
+
+def _install_reporter(monkeypatch, tmp_path, installed=True):
+ """Point CODE_EXTENSIONSDIR at a temp extensions dir, optionally
+ containing the artifact the postStart vsix install would leave behind
+ (code-server unpacks to .-/)."""
+ ext_dir = tmp_path / "extensions"
+ ext_dir.mkdir(exist_ok=True)
+ if installed:
+ (ext_dir / "nebari.nebari-activity-reporter-0.1.0").mkdir()
+ monkeypatch.setenv("CODE_EXTENSIONSDIR", str(ext_dir))
+ return ext_dir
+
+
+def test_vscode_entry_registered_alongside_nebi(monkeypatch):
+ monkeypatch.delenv("VSCODE_PROXY_UPDATE_LAST_ACTIVITY", raising=False)
+ monkeypatch.delenv("CODE_EXTENSIONSDIR", raising=False)
+ c = FakeConfig()
+ _load_image_config(c)
+ servers = c.ServerProxy.servers
+ assert "nebi" in servers, "vscode registration must not clobber nebi"
+ assert "vscode" in servers
+
+
+def test_vscode_counts_proxy_traffic_by_default_without_chart_plumbing(monkeypatch):
+ """Fail-safe default: with the env var absent (e.g. an image deployed
+ without the chart's opt-in plumbing), the image falls back to the OLD
+ behavior (counting proxied traffic as activity), so chart/image skew
+ over-spends rather than culling active users with no reporter."""
+ monkeypatch.delenv("VSCODE_PROXY_UPDATE_LAST_ACTIVITY", raising=False)
+ c = FakeConfig()
+ _load_image_config(c)
+ assert c.ServerProxy.servers["vscode"]["update_last_activity"] is True
+
+
+def test_vscode_chart_optin_disables_activity_counting(monkeypatch, tmp_path):
+ """The chart opts pods into the new behavior by setting the env var to
+ "false" when vscodeActivity.enabled is true — effective only with the
+ reporter artifact present (shared fate)."""
+ monkeypatch.setenv("VSCODE_PROXY_UPDATE_LAST_ACTIVITY", "false")
+ _install_reporter(monkeypatch, tmp_path, installed=True)
+ c = FakeConfig()
+ _load_image_config(c)
+ assert c.ServerProxy.servers["vscode"]["update_last_activity"] is False
+
+
+def test_vscode_optin_ineffective_without_reporter_artifact(monkeypatch, tmp_path):
+ """Shared fate: the chart env var and the postStart vsix install live in
+ separate failure domains. If the install failed (no artifact in the
+ extensions dir), disabling proxy activity would cull actively-working
+ users with no keep-alive channel — so the opt-in must NOT take effect
+ and the pod degrades to over-spending instead."""
+ monkeypatch.setenv("VSCODE_PROXY_UPDATE_LAST_ACTIVITY", "false")
+ _install_reporter(monkeypatch, tmp_path, installed=False)
+ c = FakeConfig()
+ _load_image_config(c)
+ assert c.ServerProxy.servers["vscode"]["update_last_activity"] is True
+
+
+def test_vscode_reporter_check_uses_default_extensions_dir(monkeypatch, tmp_path):
+ """With CODE_EXTENSIONSDIR unset, the shared-fate check must look in
+ code-server's default extensions dir (~/.local/share/code-server/
+ extensions) — the same place the postStart install writes to."""
+ monkeypatch.setenv("VSCODE_PROXY_UPDATE_LAST_ACTIVITY", "false")
+ monkeypatch.delenv("CODE_EXTENSIONSDIR", raising=False)
+ monkeypatch.setenv("HOME", str(tmp_path))
+ default_dir = tmp_path / ".local/share/code-server/extensions"
+ (default_dir / "nebari.nebari-activity-reporter-0.1.0").mkdir(parents=True)
+ c = FakeConfig()
+ _load_image_config(c)
+ assert c.ServerProxy.servers["vscode"]["update_last_activity"] is False
+
+
+def test_vscode_escape_hatch_env_restores_activity_counting(monkeypatch, tmp_path):
+ """An explicit "true" env var (e.g. set manually via extraEnv) restores
+ the old activity-counting behavior regardless of chart plumbing —
+ even with the reporter installed."""
+ monkeypatch.setenv("VSCODE_PROXY_UPDATE_LAST_ACTIVITY", "true")
+ _install_reporter(monkeypatch, tmp_path, installed=True)
+ c = FakeConfig()
+ _load_image_config(c)
+ assert c.ServerProxy.servers["vscode"]["update_last_activity"] is True
+
+
+def test_vscode_command_matches_previous_package_contract(monkeypatch):
+ """Drop-in replacement for jupyter-vscode-proxy's generated command."""
+ monkeypatch.delenv("CODE_EXTENSIONSDIR", raising=False)
+ monkeypatch.delenv("CODE_WORKINGDIR", raising=False)
+ c = FakeConfig()
+ _load_image_config(c)
+ cmd = c.ServerProxy.servers["vscode"]["command"]
+ assert cmd[0] == "code-server"
+ assert "--auth" in cmd and "none" in cmd
+ assert "--disable-telemetry" in cmd
+ assert "--port={port}" in cmd
+ assert cmd[-1] == "." # CODE_WORKINGDIR default
+
+
+def test_vscode_command_honors_code_extensionsdir(monkeypatch):
+ monkeypatch.setenv("CODE_EXTENSIONSDIR", "/custom/ext")
+ c = FakeConfig()
+ _load_image_config(c)
+ cmd = c.ServerProxy.servers["vscode"]["command"]
+ assert "--extensions-dir" in cmd
+ assert "/custom/ext" in cmd
+
+
+def test_vscode_launcher_entry_and_icon_exist(monkeypatch):
+ monkeypatch.delenv("VSCODE_PROXY_UPDATE_LAST_ACTIVITY", raising=False)
+ c = FakeConfig()
+ _load_image_config(c)
+ entry = c.ServerProxy.servers["vscode"]["launcher_entry"]
+ assert entry["title"] == "VS Code"
+ assert Path(entry["icon_path"]).name == "code-server.svg"
+ assert (REPO_ROOT / "images" / "nebi" / "icons" / "code-server.svg").exists()
diff --git a/values.yaml b/values.yaml
index c8b73a5..0d71d4f 100644
--- a/values.yaml
+++ b/values.yaml
@@ -179,6 +179,16 @@ singleuserCuller:
server:
shutdownNoActivityTimeout: 900 # 15 min — seconds after last kernel/terminal gone before server self-terminates
+# VS Code (code-server) idle-culling behavior. When enabled (default), VS
+# Code browser traffic does NOT count as jupyter activity — the bundled
+# nebari-activity-reporter extension reports real user interaction (typing,
+# scrolling, terminal use) instead, so idle VS Code tabs cull on the normal
+# schedule (https://github.com/nebari-dev/data-science-pack/issues/208).
+# Set enabled: false to revert to counting raw proxied traffic (the
+# pre-#208 behavior, which keeps pods alive while any tab is open).
+vscodeActivity:
+ enabled: true
+
# Shared storage — per-group directories mounted at /shared/ in user pods.
# Requires an RWX StorageClass on the cluster.
#
@@ -504,7 +514,7 @@ jupyterhub:
# The same value sits inside profile_options.image.choices.default
# so the JupyterLab profile selector keeps showing it too.
# scripts/bump_image_tags.py syncs all three on every bump.
- image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-16c1922
+ image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-ce941be
cpu_limit: 1
cpu_guarantee: 0.5
mem_limit: "2G"
@@ -514,15 +524,15 @@ jupyterhub:
display_name: Image
choices:
default:
- display_name: "nebari-data-science-pack-jupyterlab:sha-16c1922"
+ display_name: "nebari-data-science-pack-jupyterlab:sha-ce941be"
default: true
kubespawner_override:
- image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-16c1922
+ image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-ce941be
- slug: medium-instance
display_name: "Medium Instance"
description: "4 CPU / 8 GB RAM — pandas / scikit-learn workloads on medium datasets."
kubespawner_override:
- image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-16c1922
+ image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-ce941be
cpu_limit: 4
cpu_guarantee: 2
mem_limit: "8G"
@@ -532,10 +542,10 @@ jupyterhub:
display_name: Image
choices:
default:
- display_name: "nebari-data-science-pack-jupyterlab:sha-16c1922"
+ display_name: "nebari-data-science-pack-jupyterlab:sha-ce941be"
default: true
kubespawner_override:
- image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-16c1922
+ image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-ce941be
# Terminal customization: controls Starship prompt in JupyterLab terminals.
# When false, falls back to the default bash prompt.
terminal-customization: true
@@ -597,7 +607,7 @@ jupyterhub:
# class clears current_user — id_token_hint now reaches KC).
image:
name: quay.io/nebari/nebari-data-science-pack-jupyterhub
- tag: "sha-16c1922"
+ tag: "sha-ce941be"
config:
JupyterHub:
@@ -690,7 +700,7 @@ jupyterhub:
# keeps the two in sync on every automated bump instead.
initContainers:
- name: merge-ca-bundle
- image: quay.io/nebari/nebari-data-science-pack-jupyterhub:sha-16c1922
+ image: quay.io/nebari/nebari-data-science-pack-jupyterhub:sha-ce941be
imagePullPolicy: IfNotPresent
# z2jh's pod-level securityContext sets ``runAsNonRoot: true`` but
# no explicit uid, and the hub image's default ``USER`` is root,
@@ -757,7 +767,7 @@ jupyterhub:
singleuser:
image:
name: quay.io/nebari/nebari-data-science-pack-jupyterlab
- tag: "sha-16c1922"
+ tag: "sha-ce941be"
defaultUrl: "/lab"
extraEnv:
JUPYTERHUB_SINGLEUSER_APP: "jupyter_server.serverapp.ServerApp"