Repository navigation
Interaction-based idle culling for VS Code (code-server) - #226
Conversation
Design for #208: stop counting VS Code proxy keepalives as jupyter activity, report real user interaction via a server-side code-server extension, and wire CODE_SERVER_IDLE_TIMEOUT_SECONDS to the idle culler timeout.
Also records pre-implementation corrections in the spec: registration moves to the image-owned jupyter_server_config.py, vsix packaging uses a stdlib script instead of vsce, and e2e adapts to the kind/HTTP harness.
…wiring Final-review fixes for the VS Code idle-culling feature (issue #208): - Flip VSCODE_PROXY_UPDATE_LAST_ACTIVITY polarity: absent/empty now means the image's OLD behavior (count proxied traffic as activity); the chart actively sets "false" to opt pods into the new interaction-based behavior. Chart/image skew now fails safe (over-spend) instead of culling active users who lack the activity-reporter extension. - Correct the culling mental model in the design spec and configuration docs: configurable-http-proxy still sees websocket activity on the /vscode/ route regardless of update_last_activity, so the hub-level jupyterhub.cull culler is not fixed by this change. The load-bearing mechanism is the in-pod singleuserCuller.server.shutdownNoActivityTimeout. Extended the e2e module docstring to note the in-pod curls bypass CHP and cannot observe that hub-side signal. - postStart install now runs under `timeout 60` so a hung install cannot stall pod startup, and mirrors CODE_EXTENSIONSDIR via ${CODE_EXTENSIONSDIR:+--extensions-dir "$CODE_EXTENSIONSDIR"} so the extension installs into the same directory VS Code reads from. - Guard the cull.timeout int() conversion so a non-numeric deployer value disables the feature instead of raising and crashing the spawner config. Tests updated/added first (TDD) for each behavior change: inverted the proxy-activity env-var tests, renamed the image-config tests to match the new semantics, updated the postStart command assertion, and added boundary (61) and non-numeric cull.timeout regression tests.
|
Docs preview for |
The e2e job pulls the singleuser image tag pinned in values.yaml, so without this bump the two image-dependent VS Code idle-culling tests would run against the pre-branch image and fail. sha-7a07d8e is the Build Docker Images output for this PR's merge commit.
spawn_user waits for the pod Ready condition, but singleuser pods have no readiness probe on the jupyter port, so kubectl exec raced jupyterhub-singleuser binding :8888 (curl rc=7 in CI). Poll /api/status until it answers before the server-dependent assertions.
|
CI status note: all checks are green except The four new e2e tests pass against this branch's built image ( |
Culling behavior reference: what keeps a VS Code pod alive, and where it can cull against user intentFor reviewers (and future docs): the exhaustive list, derived from the implementation in this PR ( What keeps the pod alive (resets the in-pod idle clock)The extension reports activity (throttled to one ping / 60s) on:
Independent of the extension, the pod also stays alive through the pre-existing Jupyter-side signals: a busy notebook kernel (never culled, Where it can cull against user intent (false-idle cases)
Knobs / workarounds
|
|
Late to this and most of what I had is already in your reference post and the description, so just the bits I don't think are covered. Your item 6, extension-delivery failure. The guards listed are the e2e install tripwire and activation logging, and I think both sit at the wrong layer for the failure that'll actually happen. The chart sets Could the two share a fate? postStart writing a marker on success and
For the soak: https://redirect.github.com/coder/code-server/blob/v4.133.0/src/node/routes/index.ts#L35-L53 If that trips with a browser attached the state goes Last one, minor: Hey @krassowski , pinging you here because you might have some idea around idle culling or code-server on the Jupyter side. Short version is that an open but idle VS Code tab keeps the pod alive, since jupyter-server-proxy counts the websocket keepalives as API activity, and this PR opts the |
- CODE_SERVER_IDLE_TIMEOUT_SECONDS now derives from singleuserCuller.server.shutdownNoActivityTimeout (via _CHART_DERIVED) instead of cull.enabled/cull.timeout: the in-pod culler is the schedule idle pods actually cull on, and disabling the hub culler no longer silently turns the code-server exit timer off. - update_last_activity=False now also requires the activity-reporter artifact to be present in the extensions dir (shared fate): a failed per-pod vsix install degrades to over-spending instead of culling an actively-working user with no keep-alive channel. - Fix stale comment claiming the hub idle culler is fixed by update_last_activity; CHP route-level websocket activity defeats it regardless. - Update unit/e2e tests and docs accordingly. Addresses #226 (comment) review feedback from @viniciusdc.
|
@viniciusdc all four points were on target. What happened with each: Extension-delivery shared fate: implemented (f656491). Went with a variant of your marker idea: instead of a postStart-written marker,
Full soak on the review-fix image ( |
viniciusdc
left a comment
There was a problem hiding this comment.
Nice work — dropping the betatim/vscode-binder git dependency is the most valuable change in here.
Needs fixing before merge: the PR description describes a different design than the diff. It says CODE_SERVER_IDLE_TIMEOUT_SECONDS "is derived from jupyterhub.cull.timeout", but the code derives it from singleuserCuller.server.shutdownNoActivityTimeout and is emphatic about it — hub-config.yaml:63 never reads cull.timeout, 01-spawner.py:322 literally says "NOT the hub-level cull.timeout", there's a test called test_idle_timeout_independent_of_hub_culler, and the e2e asserts "900" while cull.timeout is 1800. The code is right and the choice is right. It's the description that's wrong, and anyone approving from it approves a different change.
That same sentence appears as a code contract in three places the diff itself falsifies — jupyter_server_config.py:87, extension.js:4, and test_vscode_server_registration.py's docstring, whose own assertions at :58/:82/:107 all say is True. update_last_activity is computed and defaults to True. Test counts are off too: 132 on main -> 155 on head, so +23 new, not 19.
Same values.yaml staleness behind the first comment also drops KeyCloakOAuthenticator.manage_groups: true (d10da09) and moves the nine image refs to sha-ce941be, which git cat-file doesn't recognise — it's a synthetic PR-merge SHA from 43cae85, so the quay tag exists but maps to no commit. The git checkout fixes all of it.
Nothing lints, type-checks or executes extension.js. Ruff is scoped to config/, docs.yml only triggers on docs/**, and the file sits outside docs/tsconfig.json and npm test — so 123 lines that the comment itself calls the load-bearing half have no coverage at all. One line in lint.yaml:
- name: Check extension syntax
run: node --check images/jupyterlab/vscode-activity-reporter/extension.jsRelated, and the reason I'd want that: _vscode_reporter_installed() globs for the extension directory, which only proves something got unzipped. A broken extension.js installs perfectly cleanly — build-vsix.py just zips bytes and --install-extension never runs the module — so the directory appears, the gate opens, update_last_activity goes False, and an actively-working user gets culled. Same for a stale 0.1.0 dir when a --force install fails, since the glob matches any version. Having activate() write a marker keyed to the extension version or vsix hash would make the gate mean "the reporter actually ran" — a plain marker on the PVC goes sticky and won't survive a later broken upgrade.
CODE_EXTENSIONSDIR and CODE_WORKINGDIR are set nowhere in the repo — no values.yaml, no extraEnv, no Dockerfile, no spawner env, no docs. Only readers and test monkeypatches. They're betatim/vscode-binder's env contract and that package is gone, so the os.path.expanduser(...) fallback is the only branch that ever runs. Deleting them takes jupyter_server_config.py:109-112 and :128-130, the ${CODE_EXTENSIONSDIR:+...} expansion plus its comment at 01-spawner.py:1050-1058, 5 docstring lines at test_nss_wrapper_shared_dir.py:170-174, and two tests — and removes the reason the "keep the install location in sync" comment exists.
Docs: values-reference.md has no vscodeActivity entry, and per convention that's where field detail lives. Also configuration.md:74 — "set it to 0 to disable this feature" reads like it's about VS Code culling when 0 disables in-pod self-termination for notebooks too. The next bullet already glosses zero correctly, so it's really just the antecedent of "this feature" that's ambiguous.
A few smaller things, not worth blocking:
- Don't drop mechanism 2. It looks like ~118 redundant lines next to mechanism 1, but it's what bounds the terminal leak above once the tab closes — without it a leaked counter keeps the ext host pinging after disconnect and the pod never culls at all. It's the backstop for mechanism 1's failure mode.
install-code-server.sh:8,14write the version twice and the URL doesn't interpolate it, so a future bump touching one verifies tag A's installer while installing version B — andexpected_sumbinds to no version, so it keeps passing silently.wget --quiet ".../v${CODE_SERVER_VERSION}/install.sh"fixes it. The unchangedexpected_sumitself is correct, by the way — both tags'install.share byte-identical and both match the pin, so nothing is broken there.- Nothing logs which mode a pod landed in, and the two failure modes are "bills 24/7" and "kills an active user's session". A single
print()of the effective mode is the cheapest thing in this PR. test_code_server_idle_timeout_env_matches_inpod_cullerandtest_activity_reporter_extension_installedare both "spawn a pod, assert one static fact" and each burns a full kind cluster. Merging them drops a leg for free; the env-var one is arguably a unit test sincetest_chart_derived.pyalready renders the chart and pins"900".- Untested input worth adding: the empty string for
shutdown-no-activity-timeout. The double-gate matrix is otherwise complete.
CI is green apart from files_are_visible_and_writable_to_groupmates, which is a network flake — helm dependency update got a connection reset from hub.jupyter.org in the unrelated shared-storage test. All four new e2e tests pass.
Worth a separate issue: the checksum pins a 15 KB script that then downloads a ~100 MB tarball with zero verification (curl -> mv -> tar -xzf, no checksum anywhere in the file). Pre-existing, and coder publishes no checksum or signature asset, so closing it means pinning a self-computed sha256 and recomputing on every bump — not this PR's job.
Parts of this review were drafted using AI generative tools.
The e2e leg for test_files_are_visible_and_writable_to_groupmates failed in setup: helm dependency update got 'connection reset by peer' fetching https://hub.jupyter.org/helm-chart/index.yaml. The test never ran; the other 16 legs on the same commit passed. Retry up to 3 times with a short delay instead of losing a whole matrix leg to one dropped TCP connection.
|
@viniciusdc Looks like it was a network error that caused the CI to fail. It's green now |
- values.yaml: undo the merge regression (nebi back to v0.15/sha-5ca877a, restore KeyCloakOAuthenticator.manage_groups and main's comments) by resetting to origin/main, re-adding the vscodeActivity block, and keeping only this branch's e2e image pin (sha-ce941be) - extension.js: track busy terminals in a per-terminal Set instead of a counter so a terminal killed mid-command can't leak busy state and pin the pod alive; close handler now clears the terminal's busy bit - extension.js: drop the dead ://: IPv6 normalize line (its output was still an invalid URL, so it only ever rearranged which error the catch saw) - hub-config.yaml: nil-proof shutdown-no-activity-timeout with 'default 0' so an empty deployer value can't render a syntax error that CrashLoops the hub - e2e docstring: record the kill-terminal-mid-command soak scenario
|
Currently testing this on a live cluster |
viniciusdc
left a comment
There was a problem hiding this comment.
Hey @tylerpotts, I ran this end to end on a local NIC (kind) cluster against 613a1f0 with the CI built images, keycloak auth through the gateway, and singleuserCuller.server.shutdownNoActivityTimeout: 180 so each run takes minutes instead of 15+ (just for quick iteration on testing). I watched the in-pod last_activity, the code-server extension host log and the reporter's output channel on every run.
The #208 fix itself holds up. To guarantee the control was working as expected, with vscodeActivity.enabled: false an idle background VS Code tab bumped last_activity 49 times in 8 minutes and the pod never culled, so the bug reproduces here. Now, with it on, the same idle tab culled on schedule (No kernels for 186 seconds; shutting down), both with the reporter active and without it. And once the reporter is running it does keep a working user alive: pings every 60 to 80s while typing, and the startup ping lands ~90ms after activation. So all working as expected to address the original issue 🚀
One small caveat found while testing, and it blocks this for me: the reporter never activates in VS Code's default state. For new users/sessions code-server opens /home/jovyan in Restricted Mode, and the extension doesn't declare untrustedWorkspaces support, so VS Code disables it until the user clicks Trust. Nothing on the pack side notices, because _vscode_reporter_installed() only checks that the directory exists, so the proxy opt-out still applies. Net effect: a user who never trusts the folder gets culled at shutdownNoActivityTimeout while actively typing, with nothing in any log.
Repro from my run, Restricted Mode, typing continuously from ~21:43:
21:42:18 last_activity (VS Code opening)
21:43 -> typing; last_activity never moves again
21:45:27 server self-terminates
The extension host log for that session lists the usual vscode.debug-auto-launch and vscode.merge-conflict activating on onStartupFinished, but never nebari.nebari-activity-reporter, and no "Nebari Activity Reporter" output channel gets created. After trusting the folder, same image and same user, it shows up right away (_doActivateExtension nebari.nebari-activity-reporter, alongside vscode.git, which also only activates in trusted workspaces). One-line fix inline on package.json.
This is also the concrete case for the activation marker from my earlier review. The directory gate passed here while the reporter was never running, which is exactly the "installed but not actually reporting" gap. If activate() wrote the marker and jupyter_server_config.py gated on it, this would have degraded to over-spending instead of culling.
Smaller things, not blocking:
onDidChangeWindowStatecounts every state change, including blur and going inactive (inline comment).- After a cull, VS Code just loops "Attempting to reconnect" against a 302 to the hub, so the user never learns the server was stopped. Pre-existing, but this PR makes culling a VS Code session much more common, so maybe worth a line in the docs.
- code-server checks
open-vsx.org/vscode/gallery/vscode/nebari/nebari-activity-reporter/latestfor updates (404 today). The installed copy is"pinned": trueso I don't think it would auto-update, but should we claim thenebarinamespace on Open VSX anyway? - The items from my previous review body are still open: the description still says the timeout derives from
jupyterhub.cull.timeout, theupdate_last_activity=Falsewording inextension.jsand the test docstring,node --checkin lint, the unusedCODE_EXTENSIONSDIR/CODE_WORKINGDIRhandling, and the missingvscodeActivityentry invalues-reference.md.
What I didn't cover in the browser: killing a terminal mid-command (the Set fix) and the tab-closed path for CODE_SERVER_IDLE_TIMEOUT_SECONDS.
| "categories": ["Other"], | ||
| "activationEvents": ["onStartupFinished"], | ||
| "main": "./extension.js", | ||
| "contributes": {} |
There was a problem hiding this comment.
issue (blocking): without this the reporter is disabled in Restricted Mode, which is what code-server opens /home/jovyan in by default, so an untrusted user gets culled while typing (repro in the review body).
The extension only listens to editor events and makes one local HTTP request, so declaring untrusted support is safe:
| "contributes": {} | |
| "capabilities": { | |
| "untrustedWorkspaces": { "supported": true } | |
| }, | |
| "contributes": {} |
| on(vscode.workspace.onDidChangeTextDocument, "edit"); | ||
| on(vscode.window.onDidChangeTextEditorSelection, "selection"); | ||
| on(vscode.window.onDidChangeTextEditorVisibleRanges, "scroll"); | ||
| on(vscode.window.onDidChangeWindowState, "focus"); |
There was a problem hiding this comment.
suggestion (non-blocking): this fires on every window state change, so losing focus and going inactive also count as activity. In my run a quick alt-tab through the VS Code window produced one ping on focus and a second one two minutes later on the way out, which pushed the cull back by a full timeout.
Counting only transitions into focused/active keeps the intent:
context.subscriptions.push(
vscode.window.onDidChangeWindowState((s) => {
if (s.focused || s.active) {
recordActivity("focus");
}
}),
);|
going to approve this one since the main issue is addressed with this PR, and open a follow up issue for the extension for untrustedWorkspaces |
viniciusdc
left a comment
There was a problem hiding this comment.
Moving this to approval, we can have the necessary untrusted changes in a follow-up
Closes #208
Problem
An open VS Code tab holds a websocket whose keepalives flow through jupyter-server-proxy and count as Jupyter activity, so pods with an idle VS Code tab are never culled. The heartbeat itself is a local file touch; the proxied connection traffic is what defeats culling. The env var proposed in the issue (
CODE_SERVER_IDLE_TIMEOUT_SECONDS) only fires after all browser connections close (code-server's idle timer gates ongetConnections() > 0), so on its own it does not cover the open-tab case — and it doesn't exist in the previously pinned code-server 4.104.3 (added in 4.106.0 via coder/code-server#7539).What this PR does
Two composing mechanisms:
vscodejupyter-server-proxy entry is now registered by this repo (thejupyter-vscode-proxypackage is removed) withupdate_last_activity: False, so VS Code keepalives stop refreshing the in-podapi_last_activity. A bundled server-side VS Code extension (nebari-activity-reporter, plain JS, packaged to a vsix by a stdlib script at image build, installed per-user by a postStart hook) reports real interaction — typing, scrolling, terminal use, focus — and treats a running terminal command as busy (mirrorscullBusy: false). Idle-tab pods are then culled by the in-podsingleuserCuller.server.shutdownNoActivityTimeout(900s default). Note: the hub-leveljupyterhub.cullculler still sees CHP-level websocket activity while a tab is connected, so the in-pod culler is the load-bearing mechanism — documented indocs/src/content/docs/configuration.md.install.shis byte-identical between tags; hash pin unchanged) andCODE_SERVER_IDLE_TIMEOUT_SECONDSis derived fromjupyterhub.cull.timeout(skipped when culling is disabled or timeout ≤ 60, which code-server rejects), so lingering code-server processes exit on the culler's schedule after the last connection drops.Escape hatch:
vscodeActivity.enabled: falsereverts to counting raw proxied traffic. The polarity is fail-safe: an image running without the new chart plumbing defaults to the old behavior (over-spending) rather than culling active users who have no reporter installed.Testing
tests/e2e/test_vscode_idle_culling.py): proxied/vscode/traffic does not advancelast_activity; a contents-API ping does; the extension is installed; the idle-timeout env matchescull.timeout.values.yaml, so once CI publishes this branch's image I'll runscripts/bump_image_tags.pyso the two image-dependent e2e tests exercise the new image.shutdownNoActivityTimeout; active typing does not).