Files
162016b909 rage-restart followup (#92591)
### What?

Two behaviour changes to the rage-restart detection in `next dev`, plus
a refactor of the git helper code:

1. **Lower the rage-restart threshold from 120s to 90s.** The previous
window was too generous; 90 s is a better match for the workflow of
stopping and immediately restarting the dev server.

2. **Don't count a restart as a rage-restart if the git branch
changed.** A common workflow is: stop the dev server, `git checkout
<other-branch>`, restart. That sequence has nothing to do with
frustration — it's intentional. The dev state file now records the
current git branch on shutdown, and on startup the recorded branch is
compared to the current one. If they differ the restart is not flagged.
If either branch is unknown (detached HEAD, not a git repo, etc.) the
comparison is skipped and we fall back to time-only detection,
preserving the existing behaviour.

### Why?

The rage-restart signal is meant to detect genuine frustration with the
dev server (e.g. it's hanging or erroring on startup). Switching
branches is an intentional, deliberate action that happens to look the
same from a pure timing perspective. Capturing it as rage-restart
pollutes telemetry and could trigger unwanted recovery behaviour.

The git helper consolidation removes duplicated `spawnSync` / `execSync`
calls spread across unrelated files, centralises the `VERCEL_GIT_*`
env-var fallback logic, and makes it easy to add further git queries in
one place.

### How?

- `writeDevState()` in `next-dev.ts` now calls `getGitBranch(dir)` and
stores the result under a `gitBranch` key in the dev state file (omitted
when the branch is unknown).
- On startup, after reading the dev state file, the stored `gitBranch`
is compared to the freshly-read `getGitBranch(dir)`. `isRageRestart` is
only set when the branch either hasn't changed or couldn't be
determined.
- `packages/next/src/lib/helpers/git.ts` provides `gitExec()` (private),
`getGitBranch()`, and `getGitCommit()`. It replaces the deleted
`get-git-branch.ts` and the open-coded `child_process.spawnSync` calls
in `trace-uploader.ts`.

<!-- NEXT_JS_LLM_PR -->

---------

Co-authored-by: Will Binns-Smith <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-04-21 15:55:55 -07:00

122 lines
4.3 KiB
TypeScript

import { rmSync, writeFileSync, mkdirSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { execSync } from 'node:child_process'
import { getCacheDirectory } from 'next/dist/lib/helpers/get-cache-directory'
import { nextTestSetup } from 'e2e-utils'
import { parseTraceFile } from '../../../lib/parse-trace-file'
describe('rage restart trace attributes', () => {
// Use a fake upload URL so traceUploadUrl is set and detection runs.
// The URL doesn't need to be reachable — we inspect the trace file directly.
const fakeUploadUrl = 'http://localhost:19999'
const { next, isNextDev } = nextTestSetup({
files: __dirname,
skipStart: true,
startArgs: ['--experimental-upload-trace', fakeUploadUrl],
})
if (!isNextDev) {
it('should be skipped in production', () => {})
return
}
const getDevStateFilePath = () =>
join(getCacheDirectory('nextjs-nodejs'), 'dev-state.json')
beforeEach(async () => {
// Ensure each test starts from a clean stopped state with no prior session.
await next.stop('SIGTERM').catch(() => {})
rmSync(getDevStateFilePath(), { force: true })
})
const getTracePath = () => join(next.testDir, '.next/dev/trace')
const getStartDevServerSpan = () => {
const traceStructure = parseTraceFile(getTracePath())
const spans = traceStructure.eventsByName.get('start-dev-server')
expect(spans).toBeDefined()
expect(spans?.length).toBeGreaterThan(0)
// Trace file is appended across restarts; take the most recent span.
return spans?.[spans.length - 1]
}
it('should not set rage-restart on first start', async () => {
await next.start()
await next.render$('/')
await next.stop('SIGTERM')
const span = getStartDevServerSpan()
expect(span?.tags?.['rage-restart']).toBe(false)
expect(span?.tags?.['missing-next-dir']).toBe(false)
})
it('should set rage-restart when restarted within threshold', async () => {
// First session: establish a prior stop time.
await next.start()
await next.render$('/')
await next.stop('SIGTERM')
// Second session: restart immediately — should be detected as a rage restart.
await next.start()
await next.render$('/')
await next.stop('SIGTERM')
const span = getStartDevServerSpan()
expect(span?.tags?.['rage-restart']).toBe(true)
expect(span?.tags?.['missing-next-dir']).toBe(false)
})
it('should set missing-next-dir when .next is deleted before restart', async () => {
// First session: establish a prior stop time.
await next.start()
await next.render$('/')
await next.stop('SIGTERM')
// Delete .next to simulate the user clearing the build cache.
rmSync(join(next.testDir, '.next'), { recursive: true, force: true })
// Second session: restart — should detect both rage restart and missing .next.
await next.start()
await next.render$('/')
await next.stop('SIGTERM')
const span = getStartDevServerSpan()
expect(span?.tags?.['rage-restart']).toBe(true)
expect(span?.tags?.['missing-next-dir']).toBe(true)
})
it('should not set rage-restart when the git branch changed between sessions', async () => {
// next.testDir is a temp directory outside the repo, so it has no git
// context by default. Initialize a git repo there so getGitBranch() can
// return a known branch name for comparison.
execSync('git init && git checkout -b current-test-branch', {
cwd: next.testDir,
stdio: 'ignore',
})
// Write a dev-state entry that records a recent stop on a *different*
// branch, bypassing a full first session.
const devStateFilePath = getDevStateFilePath()
mkdirSync(dirname(devStateFilePath), { recursive: true })
const fakeState = {
[next.testDir]: {
stopTime: Date.now() - 1000, // 1 second ago — well within the threshold
distDirPath: join(next.testDir, '.next'),
gitBranch: 'previous-branch',
},
}
writeFileSync(devStateFilePath, JSON.stringify(fakeState))
// Start the server — the current branch ("current-test-branch") differs
// from the stored one ("previous-branch"), so this should NOT be flagged
// as a rage restart.
await next.start()
await next.render$('/')
await next.stop('SIGTERM')
const span = getStartDevServerSpan()
expect(span?.tags?.['rage-restart']).toBe(false)
})
})