fix(crash-reporting): co-time the statfs by tick, not sample identity

A tick whose host read fails leaves the pre-gone sample object in place, so
the identity check still read a 25 s-late statfs as co-timed.
This commit is contained in:
Neil
2026-09-03 04:25:37 -07:00
parent 815a0f3dd5
commit 7cd14bdbed
2 changed files with 44 additions and 3 deletions
@@ -241,6 +241,43 @@ describe('pre-gone host memory', () => {
}
})
// Round 5: sample identity alone could not see these ticks. A host read that
// returns nothing leaves the sample object in place, so `sample === issuedFor`
// still held 25 s and two ticks later and the statfs re-qualified the verdict.
it('will not let ticks with a failed host read pass a stale statfs off as co-timed', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.useFakeTimers()
let resolveVolume: (value: SwapVolumeFreeSpace) => void = () => {}
try {
setSystemMemoryInfoReaderForTest(() => BEFORE_THE_STORM)
setSwapVolumeFreeSpaceReaderForTest(
() =>
new Promise<SwapVolumeFreeSpace>((resolve) => {
resolveVolume = resolve
})
)
void samplePreGoneSystemMemory(0)
// GlobalMemoryStatusEx starts failing: the sample is neither replaced nor erased.
setSystemMemoryInfoReaderForTest(() => null)
await samplePreGoneSystemMemory(10_000)
await samplePreGoneSystemMemory(20_000)
resolveVolume({ freeMB: 40_000, volume: 'C:' })
await vi.advanceTimersByTimeAsync(0)
vi.setSystemTime(25_000)
const details = buildProcessGoneCrashDetails({}, 'renderer')
// 25 s of lag: the label must not say co-timed beside that age.
expect(details.systemMemoryPreGoneSwapVolumeAgeMs).toBe(25_000)
expect(details.systemMemoryPreGonePressureSignal).toBe('available-commit-unqualified')
} finally {
vi.useRealTimers()
Object.defineProperty(process, 'platform', platform)
}
})
it("arms the host sampler on its own unref'd 10 s timer, not the metric sweep's", async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
@@ -30,6 +30,7 @@ let preGoneSample: PreGoneSystemMemorySample | null = null
let preGoneTimer: ReturnType<typeof setInterval> | null = null
let swapVolumeReadInFlight = false
let samplingGeneration = 0
let sampleTick = 0
const PRESSURE_SIGNAL_KEY = `${SYSTEM_MEMORY_KEY_PREFIX}PressureSignal`
@@ -74,7 +75,7 @@ function commitHostMemorySample(nowMs: number): boolean {
}
}
async function mergeSwapVolumeFreeSpace(): Promise<void> {
async function mergeSwapVolumeFreeSpace(issuedOnTick: number): Promise<void> {
if (swapVolumeReadInFlight) {
return
}
@@ -87,7 +88,9 @@ async function mergeSwapVolumeFreeSpace(): Promise<void> {
// Why only its own tick qualifies: a statfs that outlived its tick carries a
// pre-storm volume number, and the latch makes that lag unbounded. It still
// ships beside its age, but it may not decide the verdict.
const coTimed = preGoneSample === issuedFor
// Why the tick counter and not sample identity: a tick whose host read fails
// leaves the sample object in place, so identity alone reads as co-timed.
const coTimed = issuedOnTick === sampleTick
preGoneSample = {
...preGoneSample,
details: withSwapVolumeFreeSpace(preGoneSample.details, volume, process.platform, coTimed),
@@ -105,10 +108,11 @@ export async function samplePreGoneSystemMemory(nowMs: number = Date.now()): Pro
// Why commit before awaiting: the volume read is a statfs, and under the very
// paging storm this targets it is slowest — it must never delay, or (via an
// in-flight latch) skip, the cheap synchronous host reading.
const tick = ++sampleTick
if (!commitHostMemorySample(nowMs)) {
return
}
await mergeSwapVolumeFreeSpace()
await mergeSwapVolumeFreeSpace(tick)
}
export function startPreGoneSystemMemorySampling(