Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 36 additions & 22 deletions dsh-plugin-desktop/src/log-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions dsh-plugin-desktop/tests/log-files.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down