From 95f9d1b7ee982b8975ab7dda04d09e429498039a Mon Sep 17 00:00:00 2001 From: vumas3r Date: Mon, 26 Oct 2026 18:42:16 -0600 Subject: [PATCH] fix: start nothing from a page newer than its ./ksim web ./ksim web reads web/index.html from disk on every request but runs the code it started with. After the Teach a domain update, a server from before it got the page's new start request, did not know "teach", and started its default instead: a full 20-task exam. Report "stale" in /api/session: whether ksim or sim/*.py changed on disk since the server started. The page treats a server that sends no "stale" as stale too, since it predates the check. While the server is stale, the lobby says to restart it and launch() starts no session. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 ++++++++++ ksim | 13 +++++++++++++ web/index.html | 6 ++++++ 3 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 970c478..9b24fcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ Notable changes to kubestronaut-sim, newest first. A version is the `VERSION` st (year.month.day.n); `./ksim help` and the top bar of the web page show the one you are running. Every entry names the commit it came in. +## Unreleased + +### Fixed + +- Choosing *Teach a domain* could start a full exam. `./ksim web` reads `web/index.html` afresh on + every request but keeps running the code it started with, so a server from before the update + read the new request as its default, a full exam. The server now reports when its code has + changed on disk since it started (a server from before this check counts as changed). The + lobby then asks you to restart it and starts nothing until you do. + ## 2026.10.26.2 — 2026-10-26 Everything below came in `e852419`. diff --git a/ksim b/ksim index 6699c91..2bfbc88 100755 --- a/ksim +++ b/ksim @@ -2165,9 +2165,17 @@ def cmd_reset(args): print(("rebuilt " if not problem else "problem rebuilding ") + f"Q{q['id']:02d}: {q['title']}") # --------------------------------------------------------------- web ---- +def code_stamp(): + """When the simulator's own code (ksim, sim/*.py) last changed on disk.""" + sim_dir = os.path.join(ROOT, "sim") + return max(os.path.getmtime(f) for f in [os.path.join(ROOT, "ksim")] + + [os.path.join(sim_dir, n) for n in os.listdir(sim_dir) if n.endswith(".py")]) + + class Api(http.server.BaseHTTPRequestHandler): """Serves web/index.html plus a small JSON API. State lives in the same files the CLI uses.""" lock = threading.Lock() + started_code = None # code_stamp() when ./ksim web started log_lines = [] seeding = False seed_error = None @@ -2345,6 +2353,10 @@ class Api(http.server.BaseHTTPRequestHandler): "k8s": k8s_version(), "pending": len(PENDING), "drilled": drilled_domains(), "sets": {k: {"name": v, "count": len(set_questions(k))} for k, v in SETS.items()}, "seen": sorted(last_seen()), "seeding": Api.seeding, "seed_error": Api.seed_error, + # index.html is read from disk on every request, the server's code only when it started: + # after an update the page may ask for what this server does not know (an old one read + # "teach a domain" as a full exam), so the page starts nothing until it is restarted + "stale": Api.started_code is not None and code_stamp() > Api.started_code, "session": None, } payload.update(self.path_payload()) @@ -2444,6 +2456,7 @@ def cmd_web(args): except envs.EnvError as e: Api.desktop_error = str(e) print(yellow(f" no desktop: {e}")) + Api.started_code = code_stamp() http.server.ThreadingHTTPServer.allow_reuse_address = True srv = http.server.ThreadingHTTPServer(("127.0.0.1", port), Api) # type: ignore[arg-type] print(bold(f"\n {TRACK_ID.upper()} exam UI: http://127.0.0.1:{port} (ksim {VERSION})\n") diff --git a/web/index.html b/web/index.html index 86b6414..7bf5bc7 100644 --- a/web/index.html +++ b/web/index.html @@ -397,6 +397,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
SCROLL FOR YOUR PATH AND OBJECTIVES
+
@@ -596,6 +597,9 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === ' const fmtHMS = s => { s = Math.max(0, Math.floor(s)); return [s / 3600, s % 3600 / 60, s % 60].map(pad).join(':'); }; const esc = s => String(s).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c])); const oldServer = what => `This ./ksim web was started before "${what}" existed. Stop it (Ctrl-C) and start it again.`; + // a server that sends no "stale" predates the check, so it is older than this page as well + const STALE = 'The simulator was updated after this ./ksim web started, and the server still runs the old code: it would misread what this page asks for (an old one starts a full exam instead of a domain\'s lessons). Stop it (Ctrl-C), start it again, and reload this page.'; + const stale = () => S && S.stale !== false; const openModal = id => $(id).classList.remove('hidden'); const closeModal = id => $(id).classList.add('hidden'); const live = () => !!(S && S.session && !S.session.ended_at); @@ -683,6 +687,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === ' $$('#trackPick [data-track]').forEach(el => el.onclick = () => switchTrack(el.dataset.track)); $('#ver').textContent = 'ksim ' + (S.version || '?') + (S.pending ? ` · ${S.pending} task(s) held back` : ''); $('#foot').textContent = `ksim ${S.version || '?'} · Apache-2.0 · not affiliated with the CNCF or the Linux Foundation`; + $('#staleNote').textContent = STALE; $('#staleNote').classList.toggle('hidden', !stale()); $('#heroTitle').textContent = T.name; $('#heroSub').textContent = `Kubernetes ${T.k8s}. Every task runs on its own cluster that you reach with ssh from a remote desktop, and every session runs against the clock.`; $('#hardHero').checked = hard; @@ -891,6 +896,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === ' } } async function launch(body) { + if (stale()) { closeModal('#mPicker'); return lobbyError(STALE); } closeModal('#mPicker'); $('#lobbyErr').classList.add('hidden'); $('#lobbyNote').classList.add('hidden'); $('#prepMsg').textContent = 'starting…'; $('#prepLog').textContent = ''; openModal('#mPrep'); polling = true; try { await api('/api/start', body); await waitSeeded(); ready(true); }