diff --git a/dsh-plugin-desktop/src/log-files.ts b/dsh-plugin-desktop/src/log-files.ts index 496844372c..31df5b819f 100644 --- a/dsh-plugin-desktop/src/log-files.ts +++ b/dsh-plugin-desktop/src/log-files.ts @@ -24,6 +24,24 @@ function rotationSegment(name: string): number { return Number(/\.(\d+)\.log$/u.exec(name)?.[1] ?? 0) } +interface SegmentState { + readonly bytes: number + readonly segment: number +} + +/** Keep the newest rotation state for one log channel from a directory entry. */ +function resumeSegmentState(entry: OwnedLogFile, prefix: string, current: SegmentState): SegmentState { + const name = entry.name + let segment: number | undefined + if (name === `${prefix}.log`) segment = 0 + else if (name.startsWith(`${prefix}.`) && name.endsWith('.log')) { + const value = name.slice(prefix.length + 1, -4) + if (/^\d+$/u.test(value)) segment = Number(value) + } + if (segment === undefined || segment < current.segment) return current + return { bytes: entry.bytes, segment } +} + function truncateUtf8(text: string, maxBytes: number): string { if (Buffer.byteLength(text) <= maxBytes) return text let bytes = 0 @@ -107,15 +125,20 @@ export class LogFileSink { /** Delete log files modified more than `days` days ago. */ purgeOlderThan(days: number): void { const cutoff = Date.now() - days * 24 * 60 * 60 * 1000 + let survivingBytes = 0 for (const entry of this.ownedFiles()) { - if (entry.modifiedAt >= cutoff) continue + if (entry.modifiedAt >= cutoff) { + survivingBytes += entry.bytes + continue + } try { unlinkSync(entry.path) } catch { - // Locked logs remain eligible for the next startup cleanup. + // Locked logs remain eligible for the next startup cleanup and still count. + survivingBytes += entry.bytes } } - this.directoryBytes = this.measureDirectoryBytes() + this.directoryBytes = survivingBytes } /** Delete every file in the directory and reset the rotation state. */ @@ -167,31 +190,22 @@ export class LogFileSink { private rollDate(suffix: string): void { this.currentDate = suffix - const all = this.loadState(suffix, false) - const error = this.loadState(suffix, true) + const allPrefix = `dsh-${suffix}` + const errorPrefix = `dsh-${suffix}.error` + let all = { bytes: 0, segment: 0 } + let error = { bytes: 0, segment: 0 } + // One directory pass feeds both channels; scanning per channel doubled the + // readdir+lstat work on every date rollover and at startup. + for (const entry of this.ownedFiles()) { + all = resumeSegmentState(entry, allPrefix, all) + error = resumeSegmentState(entry, errorPrefix, error) + } this.allBytes = all.bytes this.errorBytes = error.bytes this.allSegment = all.segment this.errorSegment = error.segment } - private loadState(suffix: string, error: boolean): { bytes: number, segment: number } { - const prefix = `dsh-${suffix}${error ? '.error' : ''}` - let current = { bytes: 0, segment: 0 } - for (const entry of this.ownedFiles()) { - const name = entry.name - let segment: number | undefined - if (name === `${prefix}.log`) segment = 0 - else if (name.startsWith(`${prefix}.`) && name.endsWith('.log')) { - const value = name.slice(prefix.length + 1, -4) - if (/^\d+$/u.test(value)) segment = Number(value) - } - if (segment === undefined || segment < current.segment) continue - current = { bytes: entry.bytes, segment } - } - return current - } - private append(kind: 'all' | 'error', line: string): void { const isAll = kind === 'all' let bytes = isAll ? this.allBytes : this.errorBytes diff --git a/dsh-plugin-desktop/tests/log-files.spec.ts b/dsh-plugin-desktop/tests/log-files.spec.ts index 5bbd0c8d96..cd70ccb623 100644 --- a/dsh-plugin-desktop/tests/log-files.spec.ts +++ b/dsh-plugin-desktop/tests/log-files.spec.ts @@ -143,6 +143,39 @@ describe('LogFileSink', () => { expect(existsSync(oldLog)).toBe(false) }) + it('resumes rotation from pre-existing segments for both channels in one pass', () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-log-resume-')) + const day = todaySuffix() + writeFileSync(join(dir, `dsh-${day}.log`), 'x'.repeat(8)) + writeFileSync(join(dir, `dsh-${day}.2.log`), 'y'.repeat(8)) + writeFileSync(join(dir, `dsh-${day}.error.log`), 'x'.repeat(8)) + writeFileSync(join(dir, `dsh-${day}.error.2.log`), 'y'.repeat(8)) + const s = new LogFileSink(dir, { maxFileBytes: 10, maxDirectoryBytes: 200 * 1024 * 1024 }) + + s.write('error', 'boom') + + expect(readFileSync(join(dir, `dsh-${day}.3.log`), 'utf8')).toBe('boom\n') + expect(readFileSync(join(dir, `dsh-${day}.error.3.log`), 'utf8')).toBe('boom\n') + }) + + it('keeps rotation resume correct across the startup cap-then-purge sequence', () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-log-boot-')) + const day = todaySuffix() + writeFileSync(join(dir, `dsh-${day}.2.log`), 'y'.repeat(8)) + const oldLog = join(dir, 'dsh-2020-01-01.log') + writeFileSync(oldLog, 'remove') + const old = new Date('2020-01-01T00:00:00Z') + utimesSync(oldLog, old, old) + const s = new LogFileSink(dir, { maxFileBytes: 10, maxDirectoryBytes: 200 * 1024 * 1024 }) + + s.enforceDirectoryCap() + s.purgeOlderThan(7) + s.writeHeader('boot!') + + expect(existsSync(oldLog)).toBe(false) + expect(readFileSync(join(dir, `dsh-${day}.3.log`), 'utf8')).toBe('boot!\n') + }) + it('clear removes all files and reopens fresh streams', () => { const { s, dir } = sink() s.write('info', 'first')