From 2ff86817150565cc7051cdca2a0485d2b52b9f21 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 13 Aug 2026 06:51:38 +0200 Subject: [PATCH] chore: add dev server supervisor to cut idle vite dev memory (#10672) * chore: add dev server supervisor and dev-only polling dormancy * fix: address review findings in dev supervisor * fix: support https mode and bound the idle reaper in dev supervisor * fix: persist dormancy install guard and hold the reaper during startup * chore: run worktree frontends under the dev supervisor * fix: keep app websockets working and reap children on sighup --- .webmux.yaml | 16 +- frontend/README_DEV.md | 18 + frontend/scripts/dev-supervisor.mjs | 426 ++++++++++++++++++ .../lib/utils/devPollingDormancy.dom.test.ts | 95 ++++ frontend/src/lib/utils/devPollingDormancy.ts | 115 +++++ frontend/src/routes/+layout.svelte | 3 + 6 files changed, 671 insertions(+), 2 deletions(-) create mode 100644 frontend/scripts/dev-supervisor.mjs create mode 100644 frontend/src/lib/utils/devPollingDormancy.dom.test.ts create mode 100644 frontend/src/lib/utils/devPollingDormancy.ts diff --git a/.webmux.yaml b/.webmux.yaml index efd06f8ec2..eb4dc09ae5 100644 --- a/.webmux.yaml +++ b/.webmux.yaml @@ -58,11 +58,19 @@ profiles: split: right workingDir: backend command: PORT=${BACKEND_PORT:-8000} cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}" + # dev-supervisor runs vite only while someone is looking at the preview, which keeps + # the worktrees nobody has open from each costing 1.1-1.7 GB. The guard keeps panes + # working on branches cut before the script landed. - id: frontend kind: command split: bottom workingDir: frontend - command: npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0 + command: >- + npm run generate-backend-client && bash -c 'export + REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}}; if [ -f + scripts/dev-supervisor.mjs ]; then exec node scripts/dev-supervisor.mjs -t + ${FRONTEND_PORT:-3000} --bind 0.0.0.0 --idle ${DEV_SUPERVISOR_IDLE:-15m}; else + exec npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0; fi' frontendOnly: runtime: host @@ -87,7 +95,11 @@ profiles: kind: command split: right workingDir: frontend - command: npm run generate-backend-client && npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0 + command: >- + npm run generate-backend-client && bash -c 'if [ -f scripts/dev-supervisor.mjs + ]; then exec node scripts/dev-supervisor.mjs -t ${FRONTEND_PORT:-3000} --bind + 0.0.0.0 --idle ${DEV_SUPERVISOR_IDLE:-15m}; else exec npm run dev -- --port + ${FRONTEND_PORT:-3000} --host 0.0.0.0; fi' agentOnly: runtime: host diff --git a/frontend/README_DEV.md b/frontend/README_DEV.md index 03d0145dfa..9848f333ae 100644 --- a/frontend/README_DEV.md +++ b/frontend/README_DEV.md @@ -90,6 +90,24 @@ You can configure another proxy to use like so: REMOTE=http://127.0.0.1:8000 REMOTE_LSP=http://127.0.0.1:3001 npm run dev ``` +### Run dev servers on demand + +A dev server costs 1.1-1.7 GB resident once a page has been browsed, which adds up when +several worktrees are open at once. `scripts/dev-supervisor.mjs` owns the port instead and +runs `vite dev` only while it is being used, spawning it on the first connection (~1s to a +served response) and stopping it once traffic stops: + +```bash +node scripts/dev-supervisor.mjs # this worktree, $FRONTEND_PORT +node scripts/dev-supervisor.mjs -t 3340:/path/wt-a -t 3350:/path/wt-b --idle 15m +``` + +`--bind 0.0.0.0` to reach it off-host, `--stats ` to record RSS samples. In dev the +app also suspends its background polling after 5 minutes of an inactive tab +(`VITE_DEV_DORMANT_MS`), so a tab left open does not keep a server resident. That last +part holds over plaintext only: with `HTTPS=true` the HMR socket is indistinguishable from +real traffic, so an open tab keeps its server alive. + ### Use a Local backend #### 1. Backend is run by docker diff --git a/frontend/scripts/dev-supervisor.mjs b/frontend/scripts/dev-supervisor.mjs new file mode 100644 index 0000000000..3b96d70094 --- /dev/null +++ b/frontend/scripts/dev-supervisor.mjs @@ -0,0 +1,426 @@ +#!/usr/bin/env node +// Keeps a worktree's `vite dev` off the machine until someone actually looks at it. +// +// The supervisor owns the public port, spawns the dev server on first connection, +// proxies to it, and kills it once traffic stops. A dev server for this frontend +// costs 1.1-1.7 GB resident once browsed, so with several worktrees in flight the +// ones nobody has open are the bulk of the cost. +// +// node scripts/dev-supervisor.mjs # this worktree, $FRONTEND_PORT +// node scripts/dev-supervisor.mjs -t 3340:/path/to/wt -t 3350:/other +// node scripts/dev-supervisor.mjs -t 3340:/path --idle 10m --stats rss.jsonl +// node scripts/dev-supervisor.mjs -t 3340:/path --bind 0.0.0.0 # reachable off-host +// +// Proxying is at the TCP layer so HTTP, the HMR websocket, and the /api proxy all +// pass through untouched. Under HTTPS=true the request head is encrypted, so an HMR +// socket cannot be told apart from real traffic: a target with no tab open is still +// reclaimed, but a tab left open keeps its server alive rather than going dormant. +import net from 'node:net' +import { spawn } from 'node:child_process' +import { appendFileSync, existsSync, readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' + +const START_TIMEOUT_MS = 180_000 +const POLL_INTERVAL_MS = 250 +const TICK_MS = 15_000 +const KILL_GRACE_MS = 5_000 +const MAX_HEAD_BYTES = 8_192 +const HEAD_TIMEOUT_MS = 30_000 +const TLS_HANDSHAKE_BYTE = 0x16 + +// Every spawned dev server, so no exit path can orphan one. +const liveChildren = new Set() + +// Children are spawned detached, so signalling the negated pid reaches vite's own workers +// too. Without that, a wedged vite leaves the esbuild/rollup processes this tool exists to +// reclaim. Falls back to the direct child if the group is already gone. +function killTree(child, signal) { + try { + process.kill(-child.pid, signal) + } catch { + try { + child.kill(signal) + } catch { + // already exited + } + } +} + +function parseDuration(text) { + const m = /^(\d+)(ms|s|m|h)?$/.exec(text) + if (!m) throw new Error(`invalid duration: ${text}`) + const scale = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[m[2] ?? 'm'] + return Number(m[1]) * scale +} + +function parseArgs(argv) { + // Loopback by default: the supervised port fronts the /api proxy to a local backend + // and serves source over /@fs, and `server.allowedHosts` does not stop a non-browser + // client from sending whatever Host header it likes. Widening is opt-in. + const opts = { targets: [], idleMs: parseDuration('15m'), bind: '127.0.0.1', stats: null } + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + const next = () => { + const value = argv[++i] + if (value === undefined) throw new Error(`${arg} needs a value`) + return value + } + if (arg === '-t' || arg === '--target') { + // First colon only: a path may contain one, and an unvalidated port would reach + // `listen(NaN)`, which quietly binds a random port instead of failing. + const value = next() + const split = value.indexOf(':') + const port = Number(split === -1 ? value : value.slice(0, split)) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`--target needs [:], got: ${value}`) + } + const cwd = split === -1 ? process.cwd() : value.slice(split + 1) + opts.targets.push({ port, cwd: path.resolve(cwd) }) + } else if (arg === '--idle') opts.idleMs = parseDuration(next()) + else if (arg === '--bind') opts.bind = next() + else if (arg === '--stats') opts.stats = path.resolve(next()) + else throw new Error(`unknown argument: ${arg}`) + } + if (opts.targets.length === 0) { + opts.targets.push({ + port: Number(process.env.FRONTEND_PORT ?? 3000), + cwd: path.resolve(process.cwd()) + }) + } + return opts +} + +function freePort() { + return new Promise((resolve, reject) => { + const probe = net.createServer() + probe.on('error', reject) + probe.listen(0, '127.0.0.1', () => { + const { port } = probe.address() + probe.close(() => resolve(port)) + }) + }) +} + +function canConnect(port) { + return new Promise((resolve) => { + const socket = net.connect(port, '127.0.0.1') + const settle = (ok) => { + socket.destroy() + resolve(ok) + } + socket.on('connect', () => settle(true)) + socket.on('error', () => settle(false)) + }) +} + +// RSS of the child's whole process tree: vite spawns workers, and the number worth +// reporting is what the machine gives back when the tree is killed. +function treeRssMb(rootPid) { + const children = new Map() + let entries + try { + entries = readdirSync('/proc') + } catch { + return null + } + for (const entry of entries) { + if (!/^\d+$/.test(entry)) continue + // Processes come and go mid-scan, so one unreadable pid must not lose the whole + // tree: a null here would silently report a running server as 0 MB. + let stat + try { + stat = readFileSync(`/proc/${entry}/stat`, 'utf8') + } catch { + continue + } + const ppid = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1]) + if (!children.has(ppid)) children.set(ppid, []) + children.get(ppid).push(Number(entry)) + } + let total = 0 + const stack = [rootPid] + while (stack.length) { + const pid = stack.pop() + try { + const status = readFileSync(`/proc/${pid}/status`, 'utf8') + total += Number(/VmRSS:\s+(\d+)/.exec(status)?.[1] ?? 0) + } catch { + continue + } + stack.push(...(children.get(pid) ?? [])) + } + return Math.round(total / 1024) +} + +class Target { + constructor({ port, cwd }, opts) { + this.port = port + this.cwd = cwd + this.opts = opts + this.name = path.basename(path.dirname(cwd)) + '/' + path.basename(cwd) + this.child = null + this.internalPort = null + this.starting = null + this.ready = false + this.lastActivity = Date.now() + this.liveSockets = new Set() + this.stopping = false + } + + log(message) { + console.log(`[${this.port} ${this.name}] ${message}`) + } + + async ensureStarted() { + // `ready`, not `child`: the port is only connectable once vite has bound it, and a + // second request arriving mid-startup would otherwise be handed a dead port. + if (this.child && this.ready) return this.internalPort + if (this.starting) return this.starting + this.starting = this.#start().finally(() => { + this.starting = null + }) + return this.starting + } + + async #start() { + const bin = path.join(this.cwd, 'node_modules/.bin/vite') + if (!existsSync(bin)) throw new Error(`no vite binary at ${bin}`) + const internalPort = await freePort() + // --strictPort so a port race fails loudly instead of vite silently binding + // elsewhere and every proxied request hanging. --host pins the child to v4 + // loopback: vite's default `localhost` can resolve to ::1 only, which the + // readiness probe and the proxy would never reach. + const child = spawn( + bin, + ['dev', '--port', String(internalPort), '--strictPort', '--host', '127.0.0.1'], + { + cwd: this.cwd, + env: { ...process.env, FRONTEND_PORT: String(internalPort) }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true + } + ) + this.child = child + this.internalPort = internalPort + this.ready = false + this.stopping = false + liveChildren.add(child) + const relay = (stream) => { + let buffered = '' + stream.setEncoding('utf8') + stream.on('data', (chunk) => { + buffered += chunk + const lines = buffered.split('\n') + buffered = lines.pop() ?? '' + for (const line of lines) if (line.trim()) this.log(` ${line}`) + }) + } + relay(child.stdout) + relay(child.stderr) + child.on('exit', (code, signal) => { + liveChildren.delete(child) + if (!this.stopping) this.log(`dev server exited unexpectedly (${signal ?? code})`) + if (this.child === child) { + this.child = null + this.internalPort = null + this.ready = false + } + }) + + this.log(`starting dev server on :${internalPort}`) + const started = Date.now() + while (Date.now() - started < START_TIMEOUT_MS) { + if (!this.child) throw new Error('dev server exited during startup') + if (await canConnect(internalPort)) { + this.log(`ready in ${((Date.now() - started) / 1000).toFixed(1)}s`) + this.ready = true + this.lastActivity = Date.now() + return internalPort + } + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)) + } + this.stop() + throw new Error('dev server did not become reachable') + } + + stop() { + if (!this.child) return + this.stopping = true + const rss = treeRssMb(this.child.pid) + this.log(`stopping dev server${rss ? ` (reclaiming ${rss} MB)` : ''}`) + const child = this.child + killTree(child, 'SIGTERM') + // Only if it is still ours to kill: the pid may have been recycled by then, and the + // group signal would land on an unrelated process group. + setTimeout(() => { + if (liveChildren.has(child)) killTree(child, 'SIGKILL') + }, KILL_GRACE_MS).unref() + this.child = null + this.internalPort = null + this.ready = false + for (const socket of this.liveSockets) socket.destroy() + this.liveSockets.clear() + } + + handle(client) { + client.on('error', () => client.destroy()) + // TCP preserves no message boundaries, so the request head can arrive split across + // segments: classify only once the header terminator is in hand, otherwise an upgrade + // split mid-header reads as ordinary traffic and the HMR socket keeps a server alive + // that nobody is watching. A TLS record (HTTPS=true) never contains that terminator, + // so it is dispatched opaquely rather than waited on forever. + const chunks = [] + let buffered = 0 + let dispatched = false + const dispatch = (head) => { + dispatched = true + clearTimeout(headTimer) + client.off('data', onData) + client.pause() + this.#dispatch(client, head) + } + const onData = (chunk) => { + chunks.push(chunk) + buffered += chunk.length + const head = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks) + if (head.length > 0 && head[0] === TLS_HANDSHAKE_BYTE) return dispatch(head) + if (head.indexOf('\r\n\r\n') === -1 && buffered < MAX_HEAD_BYTES) return + dispatch(head) + } + // Nothing may sit here forever: a speculative preconnect that never sends a request + // would otherwise leak a socket per attempt. + const headTimer = setTimeout(() => { + if (!dispatched) client.destroy() + }, HEAD_TIMEOUT_MS) + headTimer.unref() + client.on('data', onData) + } + + #dispatch(client, first) { + // Three kinds of connection, told apart by the subprotocol vite gives its own + // sockets. Vite's must not restart a reclaimed server, because its reconnect probe + // (`vite-ping`) would resurrect every one we stop. The app's `/ws/*` language-server + // and multiplayer sockets must, or a parked script editor loses its smart assistant + // until reload. Neither counts as activity: a tab nobody is looking at still + // heartbeats, and treating that as use is what pinned servers forever. + const head = first.toString('latin1', 0, Math.min(first.length, MAX_HEAD_BYTES)) + const isWebsocket = /\r\nupgrade:\s*websocket/i.test(head) + const isViteSocket = + isWebsocket && /\r\nsec-websocket-protocol:[^\r\n]*vite-(hmr|ping)/i.test(head) + if (isViteSocket && !this.child) { + client.destroy() + return + } + if (!isWebsocket) this.lastActivity = Date.now() + if (!isViteSocket) { + this.liveSockets.add(client) + // Registered at insertion, not after the upstream connects: a client that gives + // up during a cold start would otherwise stay in the set forever and silently + // wedge the idle reaper for the life of the process. + client.once('close', () => this.liveSockets.delete(client)) + } + + this.ensureStarted().then( + (port) => { + const upstream = net.connect(port, '127.0.0.1', () => { + upstream.write(first) + client.pipe(upstream) + upstream.pipe(client) + client.resume() + }) + const bump = isWebsocket ? () => {} : () => (this.lastActivity = Date.now()) + + client.on('data', bump) + upstream.on('data', bump) + const teardown = () => { + this.liveSockets.delete(client) + client.destroy() + upstream.destroy() + } + upstream.on('error', teardown) + upstream.on('close', teardown) + client.on('close', teardown) + }, + (err) => { + this.log(`failed to start: ${err.message}`) + this.liveSockets.delete(client) + client.destroy() + } + ) + } + + tick() { + if (!this.child) return + // No bytes flow while vite boots, so an `--idle` shorter than a cold start would + // otherwise reap the server the waiting client is still queued behind. + if (this.starting) return + // Staleness alone, deliberately: an open socket is not proof of use, and gating on + // one being present lets a half-open connection (VPN drop, suspend), an idle SSE + // stream, or an opaque TLS socket pin the server forever. Every byte in either + // direction bumps lastActivity, so anything genuinely in flight keeps this fresh. + if (Date.now() - this.lastActivity < this.opts.idleMs) return + this.log(`idle for ${Math.round(this.opts.idleMs / 60_000)}m`) + this.stop() + } + + sample() { + if (!this.child) return { port: this.port, cwd: this.cwd, running: false, rssMb: 0 } + return { + port: this.port, + cwd: this.cwd, + running: true, + rssMb: treeRssMb(this.child.pid) ?? 0 + } + } +} + +const opts = parseArgs(process.argv.slice(2)) +const targets = opts.targets.map((t) => new Target(t, opts)) + +for (const target of targets) { + const server = net.createServer((client) => target.handle(client)) + server.on('error', (err) => { + console.error(`[${target.port}] listen failed: ${err.message}`) + process.exit(1) + }) + server.listen(target.port, opts.bind, () => target.log(`supervising ${target.cwd}`)) +} + +setInterval(() => { + for (const target of targets) target.tick() + if (!opts.stats) return + const now = new Date().toISOString() + try { + for (const target of targets) { + appendFileSync(opts.stats, JSON.stringify({ at: now, ...target.sample() }) + '\n') + } + } catch (err) { + // An unwritable stats path must not throw out of the tick: that would leave the + // dev servers running with nothing left to reap them. + console.error(`stats write failed, continuing: ${err.message}`) + } +}, TICK_MS).unref() + +// Whatever route we leave by, the dev servers are the thing this tool exists to +// reclaim, so nothing may outlive the supervisor. +process.on('exit', () => { + for (const child of liveChildren) killTree(child, 'SIGKILL') +}) + +// SIGHUP included: as a tmux pane the supervisor is hung up when the pane closes, and +// without a listener node dies on the default disposition without running `exit` — which +// would strand the detached child, since it has its own session and misses the hangup. +for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + process.on(signal, async () => { + for (const target of targets) target.stop() + const deadline = Date.now() + KILL_GRACE_MS + while (liveChildren.size > 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)) + } + process.exit(0) + }) +} + +console.log( + `supervising ${targets.length} target(s), idle timeout ${Math.round(opts.idleMs / 60_000)}m` +) diff --git a/frontend/src/lib/utils/devPollingDormancy.dom.test.ts b/frontend/src/lib/utils/devPollingDormancy.dom.test.ts new file mode 100644 index 0000000000..90b8952c5f --- /dev/null +++ b/frontend/src/lib/utils/devPollingDormancy.dom.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +// Freshly imported per case: install is idempotent by design, so a shared module instance +// would make the second case a no-op against a restored `window.setInterval`. +async function install() { + vi.resetModules() + const module = await import('./devPollingDormancy') + module.installDevPollingDormancy() +} + +// The patch is invisible in production and has no UI, so the freeze/re-arm pair is the +// only thing standing between a forgotten tab and a dev server that never gets reclaimed. +describe('devPollingDormancy', () => { + const original = { setInterval: window.setInterval, clearInterval: window.clearInterval } + + afterEach(() => { + window.setInterval = original.setInterval + window.clearInterval = original.clearInterval + // The install guard lives on `window`, so it is part of the global state to restore. + delete (window as unknown as Record).__wmDevPollingDormancyInstalled + vi.useRealTimers() + vi.unstubAllEnvs() + vi.restoreAllMocks() + }) + + function setHidden(hidden: boolean) { + Object.defineProperty(document, 'hidden', { value: hidden, configurable: true }) + document.dispatchEvent(new Event('visibilitychange')) + } + + it('freezes intervals once the tab goes inactive and re-arms them on return', async () => { + vi.useFakeTimers() + vi.stubEnv('VITE_DEV_DORMANT_MS', '1000') + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + setHidden(false) + + await install() + + const tick = vi.fn() + const handle = window.setInterval(tick, 100) + vi.advanceTimersByTime(300) + expect(tick).toHaveBeenCalledTimes(3) + + setHidden(true) + vi.advanceTimersByTime(1000) + tick.mockClear() + vi.advanceTimersByTime(500) + expect(tick).not.toHaveBeenCalled() + + setHidden(false) + vi.advanceTimersByTime(300) + expect(tick).toHaveBeenCalledTimes(3) + + // Clearing through the patched handle must still stop the underlying timer. + window.clearInterval(handle) + tick.mockClear() + vi.advanceTimersByTime(300) + expect(tick).not.toHaveBeenCalled() + }) + + it('does not re-patch when the module itself is hot-replaced', async () => { + vi.useFakeTimers() + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + setHidden(false) + + await install() + const patched = window.setInterval + + // A fresh module instance is what HMR hands the layout on the next edit. + await install() + + expect(window.setInterval).toBe(patched) + }) + + it('arms intervals registered while dormant only once the tab is back', async () => { + vi.useFakeTimers() + vi.stubEnv('VITE_DEV_DORMANT_MS', '1000') + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + setHidden(false) + + await install() + + setHidden(true) + vi.advanceTimersByTime(1000) + + const tick = vi.fn() + window.setInterval(tick, 100) + vi.advanceTimersByTime(500) + expect(tick).not.toHaveBeenCalled() + + setHidden(false) + vi.advanceTimersByTime(200) + expect(tick).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/src/lib/utils/devPollingDormancy.ts b/frontend/src/lib/utils/devPollingDormancy.ts new file mode 100644 index 0000000000..858f83e21d --- /dev/null +++ b/frontend/src/lib/utils/devPollingDormancy.ts @@ -0,0 +1,115 @@ +// Dev-only: freeze recurring polling while nobody is looking at the tab. +// +// `scripts/dev-supervisor.mjs` reclaims a worktree's dev server once traffic stops, but a tab left +// open keeps polling /api forever and would pin it resident. Intervals are cleared while dormant +// and re-armed on the next focus, so returning to the tab costs one extra tick of staleness. + +const DEFAULT_DORMANT_AFTER_MS = 5 * 60_000 + +// Handles are offset so anything we don't recognise in clearInterval can be delegated to the +// native one: clearing an id that never existed is a no-op, mistaking one for ours is not. +const HANDLE_OFFSET = 1_000_000_000 + +type Registration = { + handler: TimerHandler + ms: number + args: unknown[] + native: number | undefined +} + +const INSTALLED_FLAG = '__wmDevPollingDormancyInstalled' + +export function installDevPollingDormancy(): void { + if (!import.meta.env.DEV || typeof window === 'undefined') return + // The root layout's body re-runs on HMR, and a second install would bind its "native" + // setInterval to the already-patched one and stack another set of window listeners. The + // guard lives on `window` rather than in module scope because hot-replacing this very + // module resets module state while leaving the previous patch in place. + const flags = window as unknown as Record + if (flags[INSTALLED_FLAG]) return + flags[INSTALLED_FLAG] = true + + const configured = import.meta.env.VITE_DEV_DORMANT_MS + const dormantAfterMs = configured ? Number(configured) : DEFAULT_DORMANT_AFTER_MS + if (!Number.isFinite(dormantAfterMs) || dormantAfterMs <= 0) return + + const nativeSetInterval = window.setInterval.bind(window) + const nativeClearInterval = window.clearInterval.bind(window) + const nativeSetTimeout = window.setTimeout.bind(window) + const nativeClearTimeout = window.clearTimeout.bind(window) + + const registrations = new Map() + let nextHandle = HANDLE_OFFSET + let dormant = false + let countdown: number | undefined + + window.setInterval = ((handler: TimerHandler, ms?: number, ...args: unknown[]): number => { + const handle = ++nextHandle + const registration: Registration = { handler, ms: ms ?? 0, args, native: undefined } + if (!dormant) { + registration.native = nativeSetInterval(handler, registration.ms, ...args) + } + registrations.set(handle, registration) + return handle + }) as typeof window.setInterval + + window.clearInterval = ((handle?: number): void => { + const registration = handle === undefined ? undefined : registrations.get(handle) + if (!registration) { + nativeClearInterval(handle) + return + } + if (registration.native !== undefined) nativeClearInterval(registration.native) + registrations.delete(handle as number) + }) as typeof window.clearInterval + + function inactive(): boolean { + return document.hidden || !document.hasFocus() + } + + function scheduleDormancy(): void { + if (dormant || countdown !== undefined) return + countdown = nativeSetTimeout(() => { + countdown = undefined + if (!inactive()) return + dormant = true + for (const registration of registrations.values()) { + if (registration.native === undefined) continue + nativeClearInterval(registration.native) + registration.native = undefined + } + console.debug( + `[dev] polling suspended after ${Math.round(dormantAfterMs / 1000)}s inactive ` + + `(${registrations.size} intervals)` + ) + }, dormantAfterMs) + } + + function wake(): void { + if (countdown !== undefined) { + nativeClearTimeout(countdown) + countdown = undefined + } + if (!dormant) return + dormant = false + for (const registration of registrations.values()) { + registration.native = nativeSetInterval( + registration.handler, + registration.ms, + ...registration.args + ) + } + console.debug('[dev] polling resumed') + } + + window.addEventListener('blur', scheduleDormancy) + window.addEventListener('focus', wake) + window.addEventListener('pointerdown', wake) + window.addEventListener('keydown', wake) + document.addEventListener('visibilitychange', () => { + if (document.hidden) scheduleDormancy() + else wake() + }) + + if (inactive()) scheduleDormancy() +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index ad64ef9917..fca03d39ec 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import { SvelteToast } from '@zerodevx/svelte-toast' import '$lib/assets/app.css' + import { installDevPollingDormancy } from '$lib/utils/devPollingDormancy' interface Props { children?: import('svelte').Snippet } @@ -23,6 +24,8 @@ document.getElementById('svelte-global-loader')?.remove() + installDevPollingDormancy() + // Prevent scrolling over number inputs from changing their value function handleWheel(e: WheelEvent) { const target = e.target as HTMLElement