diff --git a/src/main/crash-reporting/pre-gone-host-memory.test.ts b/src/main/crash-reporting/pre-gone-host-memory.test.ts index 87fcd5b5664..0df13d4fee5 100644 --- a/src/main/crash-reporting/pre-gone-host-memory.test.ts +++ b/src/main/crash-reporting/pre-gone-host-memory.test.ts @@ -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((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) diff --git a/src/main/crash-reporting/pre-gone-host-memory.ts b/src/main/crash-reporting/pre-gone-host-memory.ts index 790fad1220e..0db56796750 100644 --- a/src/main/crash-reporting/pre-gone-host-memory.ts +++ b/src/main/crash-reporting/pre-gone-host-memory.ts @@ -30,6 +30,7 @@ let preGoneSample: PreGoneSystemMemorySample | null = null let preGoneTimer: ReturnType | 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 { +async function mergeSwapVolumeFreeSpace(issuedOnTick: number): Promise { if (swapVolumeReadInFlight) { return } @@ -87,7 +88,9 @@ async function mergeSwapVolumeFreeSpace(): Promise { // 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(