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.
This commit is contained in:
Doxa Maps 2026-10-03 08:20:52 +00:00
parent 98e8cace71
commit 11ba7bff46
3 changed files with 222 additions and 1 deletions

View file

@ -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

View file

@ -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)
})
}

View file

@ -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',