From 11ba7bff463cf8efe11c40972cbb43d10cfaf2f5 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 3 Oct 2026 08:20:52 +0000 Subject: [PATCH] cloud dev server: a pull reaches open pages as ONE reload, once the tree is complete The pull loop (entry.sh) runs `git reset --hard` straight into the tree Vite watches. A landing rewrites hundreds of files one by one, and Vite answered the first of them while git was still writing the rest: an open page was reloaded onto a mix of old and new modules, and its tab clicks then fetched nothing until it was reloaded by hand. deployment/cloud/atomic-pull.mjs, added to the cloud overlay only (local dev, builds and entry.sh are unchanged; Vite restarts itself once when this config lands). The first file event opens a gate that closes once the tree has been quiet for ATOMIC_QUIET_MS (1200), never past ATOMIC_CEILING_MS (20000, under oauth2-proxy's 30 s upstream timeout). While it is open it holds the messages to the browsers (update, full-reload, prune, error: at ws.send, which Vite 5's server.hot broadcasts through and which IS environments.client.hot in Vite 6/7), every request except the backend routes (a middleware registered ahead of all others), and a config restart. When it closes: invalidate the module graph, release the requests, then exactly ONE full-reload (or the held restart). A held error is dropped, so none is buffered for the next browser. Past the ceiling it passes everything through until the tree is quiet. ATOMIC_QUIET_MS=0 or PULL_INTERVAL=0 turns it off. Measured on a scratch Vite 5.4.21 project (the version bun.lock pins) under a copy of this overlay, with a page of 50 modules in a browser: one `git reset --hard` of 312 files sent the page 27 full-reloads and 25 updates without the plugin, and exactly 1 full-reload with every module new with it. Bursts, a request mid-burst, a single edit, a broken-then-fixed module (page open and no page open), a config restart (and a failed one) and the ceiling: 27/27 checks pass. --- deployment/cloud/README.md | 1 + deployment/cloud/atomic-pull.mjs | 217 +++++++++++++++++++++++++ deployment/cloud/vite.cloud.config.mjs | 5 +- 3 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 deployment/cloud/atomic-pull.mjs diff --git a/deployment/cloud/README.md b/deployment/cloud/README.md index 69d7b3d0d..5ef20971b 100644 --- a/deployment/cloud/README.md +++ b/deployment/cloud/README.md @@ -29,6 +29,7 @@ private network only: it has none of the public mode's guards. On a public host, | `PORT` | no (`5173`) | the port the container serves on | | `DEV_SERVER` | no (`on`) | `off` runs a tenant without the Plugin Studio dev server: no Vite, no pulls, no Rudder; `/` redirects to `/cdn/`. Needs the sign-in (`OIDC_ISSUER`); refused with `ACCESS=public` | | `HMR_CLIENT_PORT`, `HMR_PROTOCOL` | behind HTTPS | `443` and `wss`, so browsers reach the hot-reload websocket | +| `ATOMIC_QUIET_MS`, `ATOMIC_CEILING_MS` | no (`1200`, `20000`) | a pull reaches open pages as ONE reload once no file has changed for `ATOMIC_QUIET_MS`: until then hot-reload messages, page and module requests and a config restart wait (never past `ATOMIC_CEILING_MS`), so no page loads a half-written tree. `ATOMIC_QUIET_MS=0` turns it off, as does `PULL_INTERVAL=0` ([atomic-pull.mjs](./atomic-pull.mjs)) | ## Where it runs diff --git a/deployment/cloud/atomic-pull.mjs b/deployment/cloud/atomic-pull.mjs new file mode 100644 index 000000000..214dff0be --- /dev/null +++ b/deployment/cloud/atomic-pull.mjs @@ -0,0 +1,217 @@ +// atomic-pull.mjs: the cloud dev server shows a landing to the browsers ALL AT ONCE, never half-written. +// Used by vite.cloud.config.mjs only: local `bun run dev` and every build are unchanged. +// +// WHY. entry.sh follows BRANCH with `git reset --hard` straight into the tree Vite serves and watches. A landing +// rewrites hundreds of files one after another, and Vite answered the first of them while git was still writing +// the rest: an open page was reloaded onto a mix of old and new modules, and its tab clicks then fetched nothing +// until it was reloaded by hand (the cloud server's log, measured). Vite also keeps an error raised while no +// browser is connected and shows it to the next browser that connects, so a module read half-written could put +// an error overlay on a page opened long after the pull had finished. +// +// HOW. The first file event under the root opens a GATE. Each further event, and each raw notification from the +// kernel that comes before one, moves its closing to QUIET ms later (ATOMIC_QUIET_MS, default 1200), never later +// than CEILING ms after it opened (ATOMIC_CEILING_MS, default 20000: below oauth2-proxy's 30 s upstream timeout, +// so a request held here is never cut off in front of it). +// While the gate is open: +// - what Vite sends the browsers about the tree (update, full-reload, prune, error) is held, not sent; +// - HTTP requests wait (pages, modules, the reconnect ping); the backend routes (/api/, /cdn/, /api-prod/, /pb/, +// /admin/) never do, they don't read the tree; +// - a dev-server restart (a config file changed) waits too, so it loads the new config once, complete. +// When the tree has been quiet for QUIET ms: every module is invalidated (a file read mid-write is never served +// from the cache), the waiting requests go on, and if anything was held the browsers get exactly ONE full-reload, +// or the waiting restart, which reloads them itself. The held messages are dropped, a stale error with them. An +// error raised while the gate is closed waits 250 ms first: a gate opening meanwhile means it came from a file +// git was still writing. One log line per gate. A writer that never stops: at the ceiling the gate lets +// everything through and stays out of the way (plain Vite) until the tree has been quiet once. +// +// WHERE IT HOOKS (read from Vite's source, 5.4 as pinned in bun.lock, and 7.3): +// - ws.send: in Vite 5 server.hot broadcasts through it (channel.send), and in Vite 6/7 server.ws IS the client +// environment's hot channel (environments.client.hot) and server.hot. Every message to a browser passes here, +// and an error that never reaches it is never buffered for the next browser either. +// - the request gate is registered from a configureServer hook with order 'pre', before every other plugin's +// middleware; Vite adds its own (transform, static files, the ping) after all of those. +// - server.restart: what Vite calls when a config file or one of its imports changes. +// - server.watcher (chokidar): add/change/unlink/addDir/unlinkDir open and extend the gate; raw only extends it. +// +// Off with ATOMIC_QUIET_MS=0, and with PULL_INTERVAL=0 (a checkout someone edits by hand keeps plain hot reload). +import { isAbsolute, relative, resolve, sep } from 'node:path' + +const HELD = new Set(['update', 'full-reload', 'prune', 'error']) +const BACKEND = /^\/(?:api|cdn|api-prod|pb|admin)(?:\/|$)/ +const EVENTS = ['add', 'change', 'unlink', 'addDir', 'unlinkDir'] +const RELOAD = { type: 'full-reload', path: '*' } +const num = (v, d) => (v === undefined || v === null || v === '' || !Number.isFinite(Number(v)) ? d : Number(v)) + +export function atomicPull(opts = {}) { + const env = opts.env || process.env + const quiet = num(opts.quietMs ?? env.ATOMIC_QUIET_MS, 1200) + const ceiling = Math.max(num(opts.ceilingMs ?? env.ATOMIC_CEILING_MS, 20000), quiet) + const grace = num(opts.errorGraceMs, 250) + const off = !(quiet > 0) || env.PULL_INTERVAL === '0' + return { + name: 'cloud-atomic-pull', + enforce: 'pre', + apply: 'serve', + configureServer: { + order: 'pre', + handler(server) { + if (!off) install(server, { quiet, ceiling, grace }) + }, + }, + } +} + +function install(server, { quiet, ceiling, grace }) { + const root = resolve(server.config.root) + const logger = server.config.logger + // Nothing to hold without a browser channel and a watcher (middleware mode, hmr or watch turned off). + if (typeof server.ws?.send !== 'function' || !server.watcher) return + const under = (file) => { + const r = relative(root, resolve(root, String(file))) + return r !== '..' && !r.startsWith('..' + sep) && !isAbsolute(r) + } + + let openedAt = 0 // when the gate opened; 0 = closed + let deadline = 0 // when it closes if nothing else changes + let timer = null + let confirm = null // the setImmediate that double-checks a deadline that has passed + let ceilingTimer = null + let passing = false // after a ceiling: plain Vite until the tree is quiet + let passDeadline = 0 + let passTimer = null + let held = 0 + let waiting = [] + let restart = null // a restart asked for while the gate was open: { force, settle: [] } + let closed = false // this server instance has closed (a restart replaced it, or the process exits) + const graceErrors = new Set() + + // Messages to the browsers. + const ws = server.ws + const send = ws.send + ws.send = function atomicPullSend(...args) { + const type = typeof args[0] === 'string' ? 'custom' : args[0]?.type + if (closed || !HELD.has(type)) return send.apply(this, args) + if (openedAt) { held++; return } + if (type === 'error' && grace > 0 && !passing) { + const t = setTimeout(() => { graceErrors.delete(t); if (!closed) send.apply(ws, args) }, grace) + graceErrors.add(t) + return + } + return send.apply(this, args) + } + + // Requests. + server.middlewares.use(function atomicPullGate(req, res, next) { + if (!openedAt || closed || BACKEND.test((req.url || '').split('?')[0])) return next() + waiting.push({ req, res, next }) + }) + + // Restarts. + const realRestart = server.restart + server.restart = function atomicPullRestart(forceOptimize) { + if (!openedAt || closed) return realRestart.call(server, forceOptimize) + restart ||= { force: false, settle: [] } + restart.force ||= !!forceOptimize + return new Promise((ok, fail) => restart.settle.push((e) => (e ? fail(e) : ok()))) + } + + const tick = () => { + timer = null + const left = deadline - Date.now() + if (left > 0) { timer = setTimeout(tick, left); return } + // A busy process can run this timer before the file events that queued up meanwhile: look again right after + // the event loop's next I/O poll has read them. + confirm = setImmediate(() => { + confirm = null + const again = deadline - Date.now() + if (again > 0) timer = setTimeout(tick, again) + else release(false) + }) + } + const passTick = () => { + const left = passDeadline - Date.now() + if (left > 0) { passTimer = setTimeout(passTick, left); return } + passTimer = null + passing = false + } + + const onEvent = (file) => { + if (closed || !under(file)) return + const now = Date.now() + if (passing) { passDeadline = now + quiet; return } + if (!openedAt) { + openedAt = now + timer = setTimeout(tick, quiet) + ceilingTimer = setTimeout(() => release(true), ceiling) + // An error sent just before the first event came from a file git had begun to write. + for (const t of graceErrors) { clearTimeout(t); held++ } + graceErrors.clear() + } + deadline = now + quiet + } + // chokidar's raw events come straight from the kernel's notification, before the file's stat that its add and + // change events wait for (on a loaded machine that stat lagged the writes by over a second): they keep an open + // gate open while git is still writing. They never open one: a read that updates a file's access time is one. + const onRaw = (_event, _path, details) => { + if (closed || !(openedAt || passing) || !under(details?.watchedPath ?? root)) return + const until = Date.now() + quiet + if (passing) passDeadline = Math.max(passDeadline, until) + else deadline = Math.max(deadline, until) + } + // First in line, ahead of Vite's own handlers, so the gate is open before anything they send. + const watcher = server.watcher + for (const ev of EVENTS) { + if (typeof watcher.prependListener === 'function') watcher.prependListener(ev, onEvent) + else watcher.on(ev, onEvent) + } + watcher.on('raw', onRaw) + + const invalidateAll = () => { + if (server.environments) for (const e of Object.values(server.environments)) e.moduleGraph?.invalidateAll?.() + else server.moduleGraph?.invalidateAll?.() + } + + function release(byCeiling) { + if (!openedAt) return + const took = Date.now() - openedAt + clearTimeout(timer); clearImmediate(confirm); clearTimeout(ceilingTimer) + timer = confirm = ceilingTimer = null + openedAt = 0 + const messages = held + const requests = waiting + const r = restart + held = 0; waiting = []; restart = null + if (byCeiling) { + passing = true + passDeadline = Date.now() + quiet + passTimer = setTimeout(passTick, quiet) + } + try { invalidateAll() } catch (e) { logger.warn(`[atomic-pull] could not invalidate the module graph: ${e?.message || e}`) } + for (const w of requests) { + if (!w.res.writableEnded && !w.res.destroyed && !w.req.socket?.destroyed) w.next() + } + if (messages && !r) send.call(ws, RELOAD) + const outcome = r ? 'server restart' : messages ? '1 full-reload' : 'no reload' + const why = byCeiling ? `; ceiling ${ceiling} ms reached, passing through until the tree is quiet` : '' + logger.info(`[atomic-pull] held ${messages} messages, ${requests.length} requests for ${took} ms; ${outcome}${why}`, { timestamp: true }) + if (r) runRestart(r, messages) + } + + async function runRestart(r, messages) { + let err = null + try { await realRestart.call(server, r.force) } catch (e) { err = e } + for (const settle of r.settle) settle(err) + // A failed restart leaves this server serving (Vite logs "server restart failed"): reload the pages from here. + if (messages && !closed && server.ws === ws) send.call(ws, RELOAD) + } + + server.httpServer?.once('close', () => { + closed = true + clearTimeout(timer); clearImmediate(confirm); clearTimeout(ceilingTimer); clearTimeout(passTimer) + for (const t of graceErrors) clearTimeout(t) + graceErrors.clear() + waiting = [] // their sockets closed with the server + for (const ev of EVENTS) watcher.off?.(ev, onEvent) + watcher.off?.('raw', onRaw) + }) +} diff --git a/deployment/cloud/vite.cloud.config.mjs b/deployment/cloud/vite.cloud.config.mjs index 127d91b1c..bdab3eb7a 100644 --- a/deployment/cloud/vite.cloud.config.mjs +++ b/deployment/cloud/vite.cloud.config.mjs @@ -4,6 +4,7 @@ import base from '/app/repo/vite.config.js' import { mergeConfig } from 'vite' import { readFileSync } from 'node:fs' import { GATED_PROFILES } from '/app/repo/internal/_tree-name.mjs' +import { atomicPull } from './atomic-pull.mjs' const env = process.env const SETUP_STATE = '/tmp/rudder-setup.state' // written by entry.sh: running -> done | failed @@ -74,7 +75,9 @@ export default async (e) => { ? { clientPort: Number(env.HMR_CLIENT_PORT), protocol: env.HMR_PROTOCOL || 'wss' } : undefined return mergeConfig(b, { - plugins: [publicGuard, rudderStarting], + // atomicPull: the pull loop's `git reset --hard` reaches the browsers as ONE reload once the tree is complete, + // never half-written (atomic-pull.mjs). Its request gate goes ahead of every other middleware. + plugins: [atomicPull(), publicGuard, rudderStarting], cacheDir: '/tmp/vite-cache', server: { host: env.VITE_HOST || '0.0.0.0',