[saas-e2e] reduce disk IO and webpack overhead (#36997)

* [monorepo] reduce writes from chrome in cypress

Chrome puts its shared memory in /dev/shm, and docker's default 64MB is far
too small for it - which is why Cypress passes --disable-dev-shm-usage,
spilling those segments to files on the container's writable layer instead.
Under one spec that is a few hundred MB of write-then-delete churn; under the
CI fan-out it was most of the disk bandwidth on the whole box.

So every service built from dockerfiles/cypress gets shm_size 1gb and every
cypress config drops the flag again: the SaaS e2e service, web's CI and local
component-test services plus its standalone cypress one, and server-ce's e2e
and native cypress services. Both e2e-open variants inherit the setting
through `extends`; the native one is a service of its own, and would be left
running Chrome against 64MB once the flag is gone.

* [monorepo] adopt cypress-pnp-reporter

Materializing the reporter packages copied 267 packages / 3779 files
(59MB) into /tmp on every container start, once per spec. Resolving them
through PnP inside Cypress's Electron process instead drops that to
zero: the entrypoint shell went from 55.6MB of write_bytes to 0.

* [web] serve built assets from a prepared buffer between builds

Serving a chunk cost ~2ms whatever its size, and an editor page pulls ~144
of them, so webpack-dev-server burned 2-3 cores through an e2e run purely
answering requests for output that had not changed since the last build.

It is not bytes: cutting the bundle 2.6x with a cheaper devtool moved CPU
by 8%, and an empty 304 cost nearly as much as the full body. It is the
per-request work - reading the asset out of the in-memory filesystem and
re-hashing the whole body for a strong ETag, on every request.

Three things a build does that this has to respect, each found by tracing
the hooks rather than by reading them:

- `done` fires twice per build, for the compilation and for the manifest,
  both with the same hash. A flag would have been cleared by the second
  while the first was still running, so the gate is a counter.
- An incremental `done` reports only the assets it rewrote - 16, not 630 -
  so asset names accumulate. Resetting them on `invalid` left the
  manifest's two behind and nothing was served from here again.
- webpack-dev-middleware holds requests during a rebuild. This sits in
  front of it, so it steps aside for that window and drops the cache when
  a rebuild starts. Whether a response can be kept is then decided as it
  ends rather than as it began: an epoch bumped on `invalid` names the
  build its bytes were read from, and only a response from the current
  build with no rebuild in flight is stored. A rebuild starting
  mid-response can no longer write the previous build's bytes in behind
  the cache being dropped, which eviction by asset name could not catch
  anyway - an incremental build does not name the assets it left alone.

Measured on the editor entrypoint, 16 concurrent clients:
0.26-0.31s -> 0.045-0.050s of webpack CPU per page load, and 0.0455s
immediately after a rebuild. Same chunk graph, byte-identical output.

GitOrigin-RevId: 08b4ab2d665414069bc01b4763dd64faa9b4e5cb
This commit is contained in:
Jakob Ackermann
2026-09-17 12:07:28 +00:00
committed by Copybot
parent 44910202f3
commit 37e27bb22e
11 changed files with 314 additions and 14 deletions
+13 -5
View File
@@ -1,13 +1,12 @@
import { defineConfig } from 'cypress'
import { webpackConfig } from './cypress/support/webpack.cypress'
// Cypress's Electron process uses PackherdModuleLoader which doesn't go through
// PnP hooks. Reporter packages are materialized into /tmp/cypress-reporter by
// scripts/materialize-cypress-reporter.js at container startup.
// Cypress builds reporters in its Electron process, which has no Yarn PnP.
// @overleaf/cypress-pnp-reporter loads cypress-multi-reporters there.
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/tmp/cypress-reporter/node_modules/cypress-multi-reporters',
reporter: require.resolve('@overleaf/cypress-pnp-reporter'),
reporterOptions: {
configFile: 'cypress/cypress-multi-reporters.json',
},
@@ -29,7 +28,16 @@ export default defineConfig({
webpackConfig,
},
setupNodeEvents(on, config) {
//
on('before:browser:launch', (browser, launchOptions) => {
if (browser.family === 'chromium') {
// Cypress adds this for docker's default 64MB /dev/shm; the compose services raise shm_size instead, so Chrome's shared memory stays in RAM.
const noDevShm = launchOptions.args.indexOf('--disable-dev-shm-usage')
if (noDevShm !== -1) {
launchOptions.args.splice(noDevShm, 1)
}
}
return launchOptions
})
},
specPattern:
process.env.CYPRESS_SPEC_PATTERN ||
+3 -4
View File
@@ -40,9 +40,8 @@
"type-check:backend": "tsc -p tsconfig.backend.json --noEmit",
"extract-translations": "i18next-scanner",
"convert-themes": "node frontend/js/features/source-editor/themes/scripts/convert.js",
"materialize-reporter": "node ../../bin/shared/materialize-cypress-reporter.js /tmp/cypress-reporter",
"cypress:open-ct": "yarn run materialize-reporter && OVERLEAF_CONFIG=$(pwd)/config/settings.webpack.js cypress open --component",
"cypress:run-ct": "yarn run materialize-reporter && OVERLEAF_CONFIG=$(pwd)/config/settings.webpack.js cypress run --component --browser chrome",
"cypress:open-ct": "OVERLEAF_CONFIG=$(pwd)/config/settings.webpack.js cypress open --component",
"cypress:run-ct": "OVERLEAF_CONFIG=$(pwd)/config/settings.webpack.js cypress run --component --browser chrome",
"cypress:docker:open-ct": "USER_UID=$(id -u) USER_GID=$(id -g) docker compose -f docker-compose.cypress.yml run --rm cypress yarn run cypress:open-ct",
"cypress:docker:run-ct": "USER_UID=$(id -u) USER_GID=$(id -g) docker compose -f docker-compose.cypress.yml run --rm cypress yarn run cypress:run-ct --browser chrome",
"lezer-latex:generate": "node scripts/lezer-latex/generate.mjs",
@@ -228,6 +227,7 @@
"@lezer/lr": "1.4.7",
"@lezer/markdown": "1.6.3",
"@overleaf/codemirror-tree-view": "^0.1.3",
"@overleaf/cypress-pnp-reporter": "workspace:*",
"@overleaf/dictionaries": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.3.tar.gz",
"@overleaf/eslint-plugin": "workspace:*",
"@overleaf/ranges-tracker": "workspace:*",
@@ -332,7 +332,6 @@
"csv-parse": "^6.2.1",
"csv-stringify": "^6.7.0",
"cypress": "15.20.1",
"cypress-multi-reporters": "^2.0.5",
"cypress-plugin-tab": "^1.0.5",
"d3": "^3.5.16",
"daterangepicker": "2.1.27",
@@ -0,0 +1,95 @@
/**
* Serve each built asset from a prepared response, reused until it is rebuilt.
*
* webpack-dev-middleware redoes the same work on every request: it looks the
* file up in the in-memory filesystem, reads it, and hashes the whole body to
* produce a strong ETag. Most of that output does not change between builds, so
* it is recomputed for an answer already known - and under CI ~30 browsers
* request the same chunks over and over.
*
* Only what the compilation emitted is served from here, taken from its own
* asset list rather than an extension allowlist: a build emits .js and .css but
* also .bcmap, .dic, .aff, .pfb and .woff2, and anything else reaching this
* middleware belongs to somebody else.
*
* Two things a build does that this has to respect:
*
* - It reports `done` more than once - once for the compilation and once for the
* assets-manifest plugin's own compilation, which has two assets in it - and
* an incremental `done` reports only the assets it rewrote, 16 rather than 630.
* So asset names only ever accumulate, and the gate on a running build is a
* counter rather than a flag, which the manifest's `done` would reopen while
* the compilation it belongs to is still running.
* - While a rebuild is running, webpack-dev-middleware holds requests until it
* finishes. This sits in front of it, so it steps aside for that window and
* drops the cache when a rebuild starts. Whether a response can be kept is
* decided when it ends rather than when it began: an epoch, bumped on every
* `invalid`, names the build its bytes were read from, and only a response
* from the current build with no rebuild in flight is stored. Nothing a
* rebuild has invalidated can land behind it, so every entry in the cache
* belongs to the build that is current and none has to be evicted by name.
*/
module.exports = function serveBuiltAssets(devServer) {
const cache = new Map()
const known = new Set()
const compiler = devServer.compiler
let building = 0
let epoch = 0
compiler.hooks.invalid.tap('serve-built-assets', () => {
building++
epoch++
cache.clear()
})
compiler.hooks.done.tap('serve-built-assets', stats => {
building = Math.max(0, building - 1)
for (const asset of stats.toJson({ all: false, assets: true }).assets) {
if (asset.name) known.add('/' + asset.name)
}
})
return function serveBuiltAsset(req, res, next) {
if (building > 0) return next()
if (req.method !== 'GET' && req.method !== 'HEAD') return next()
// Ranges are served from the file itself; this only deals in whole bodies.
if (req.headers.range) return next()
const url = req.url.split('?')[0]
if (!known.has(url)) return next()
const hit = cache.get(url)
if (hit) {
if (req.headers['if-none-match'] === hit.headers.etag) {
res.statusCode = 304
return res.end()
}
for (const [name, value] of Object.entries(hit.headers)) {
res.setHeader(name, value)
}
res.statusCode = 200
return req.method === 'HEAD' ? res.end() : res.end(hit.body)
}
// Miss: let webpack-dev-middleware answer, and keep what it produced.
const readEpoch = epoch
const chunks = []
const write = res.write.bind(res)
const end = res.end.bind(res)
res.write = (chunk, ...rest) => {
if (chunk) chunks.push(Buffer.from(chunk))
return write(chunk, ...rest)
}
res.end = (chunk, ...rest) => {
if (chunk && typeof chunk !== 'function') chunks.push(Buffer.from(chunk))
const headers = res.getHeaders()
// Kept only while the build these bytes came from is the current one: a rebuild starting mid-response leaves them one behind.
const current = building === 0 && epoch === readEpoch
if (current && res.statusCode === 200 && headers.etag && chunks.length) {
cache.set(url, { body: Buffer.concat(chunks), headers })
}
return end(chunk, ...rest)
}
next()
}
}