mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(spill-local): exact-shape root/session matching and prune discovered roots
Tighten the startup sweep to backend-generated name shapes and fix the tests
that had drifted from the SweepRoot-based API:
- Match roots by the exact `dsh-spill-<6>` mkdtemp shape and session dirs by
`session-<12 hex>` (DEFAULT_ROOT_RE / SESSION_DIR_RE), replacing loose
startsWith checks so foreign or fixture-shaped directories are never swept.
- Carry `SweepRoot { path, pruneWhenEmpty }` through SweepOptions so a discovered
prior-default root is removed once emptied while the active root is never
pruned; lstat each session entry so a symlinked session dir is not followed.
- Fix the tests to the SweepRoot API: import SweepRoot, correct the gatherRoots
override return shapes, build discovery fixtures with the real mkdtemp shape,
and route the warn-wiring test through a deterministic failure path.
This commit is contained in:
@@ -16,10 +16,10 @@ import z from '@deepseek-ai/schemastery'
|
||||
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
|
||||
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import { discoverDefaultRoots, privateRoot, saveTextFile, sweepSpillRoots } from './store.ts'
|
||||
import type { WarnFn } from './store.ts'
|
||||
import type { SweepRoot, WarnFn } from './store.ts'
|
||||
|
||||
export { discoverDefaultRoots, encodeSegment, isErrno, privateRoot, saveTextFile, sessionDir, sweepSpillRoots, DEFAULT_ROOT_PREFIX } from './store.ts'
|
||||
export type { SavedText, SaveTextOptions, SweepOptions, WarnFn } from './store.ts'
|
||||
export type { SavedText, SaveTextOptions, SweepOptions, SweepRoot, WarnFn } from './store.ts'
|
||||
|
||||
/** Milliseconds in one day — converts the `cleanupPeriodDays` config to the sweep cutoff. */
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||
@@ -115,19 +115,25 @@ export class LocalSpillStore extends SpillStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* The roots the startup sweep covers: the prior default `dsh-spill-*` temp
|
||||
* roots (see {@link discoverDefaultRoots}) plus the configured/active root,
|
||||
* de-duplicated (the active root may itself be a discovered default). A test
|
||||
* The roots the startup sweep covers: each discovered prior-default
|
||||
* `dsh-spill-*` temp root (see {@link discoverDefaultRoots}), pruned when
|
||||
* emptied, plus the active/configured root, which is swept but NEVER pruned
|
||||
* (the live process is still writing into it). The active root is de-duped out
|
||||
* of the discovered set so it is not swept twice or marked prunable. A test
|
||||
* overrides this to inject an isolated root set — and, being the sweep's one
|
||||
* async gather point, to hold the sweep open across a disposal for the
|
||||
* quiescence check; it is a test seam, not a deployment knob.
|
||||
*
|
||||
* @param warn - sink for a contained discovery failure.
|
||||
* @returns The absolute roots to sweep.
|
||||
* @returns The roots to sweep, each flagged for prune-when-empty.
|
||||
*/
|
||||
protected async gatherRoots(warn: WarnFn): Promise<string[]> {
|
||||
protected async gatherRoots(warn: WarnFn): Promise<SweepRoot[]> {
|
||||
const discovered = await discoverDefaultRoots(warn, this.defaultRootsBase())
|
||||
return discovered.includes(this.root) ? discovered : [...discovered, this.root]
|
||||
const roots: SweepRoot[] = discovered
|
||||
.filter(path => path !== this.root)
|
||||
.map(path => ({ path, pruneWhenEmpty: true }))
|
||||
roots.push({ path: this.root, pruneWhenEmpty: false })
|
||||
return roots
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,23 @@ import { tmpdir } from 'node:os'
|
||||
*/
|
||||
export const DEFAULT_ROOT_PREFIX = 'dsh-spill-'
|
||||
|
||||
/**
|
||||
* A backend-generated default root name: `dsh-spill-` plus the 6-character
|
||||
* suffix `mkdtemp` appends (see {@link privateRoot}). Discovery matches this
|
||||
* EXACT shape, not the bare prefix, so an unrelated `dsh-spill-test-*` fixture
|
||||
* or a foreign tool's differently-shaped `dsh-spill-…` directory is never
|
||||
* mistaken for a backend root to sweep.
|
||||
*/
|
||||
const DEFAULT_ROOT_RE = /^dsh-spill-[A-Za-z0-9]{6}$/
|
||||
|
||||
/**
|
||||
* A backend-generated session directory name: `session-` plus the 12 lowercase
|
||||
* hex characters {@link sessionDir} derives from `sha256(sessionId)`. The sweep
|
||||
* only descends into entries of this EXACT shape, so an unrelated
|
||||
* `session-backup` directory under a shared configured root is never swept.
|
||||
*/
|
||||
const SESSION_DIR_RE = /^session-[0-9a-f]{12}$/
|
||||
|
||||
let defaultRoot: string | undefined
|
||||
|
||||
/**
|
||||
@@ -126,10 +143,23 @@ export async function saveTextFile(options: SaveTextOptions): Promise<SavedText>
|
||||
/** A one-argument warning sink — the sweep's only side effect on failure (never throws). */
|
||||
export type WarnFn = (message: string) => void
|
||||
|
||||
/** One root to sweep, plus whether an emptied root directory should itself be pruned. */
|
||||
export interface SweepRoot {
|
||||
/** Absolute spill root to sweep. */
|
||||
path: string
|
||||
/**
|
||||
* When `true`, remove the root directory itself once its last `session-*`
|
||||
* child is pruned. Set for DISCOVERED prior-default `dsh-spill-*` roots (one
|
||||
* per past process — otherwise they accumulate empty forever), never for the
|
||||
* active/configured root the live process is still writing into.
|
||||
*/
|
||||
pruneWhenEmpty: boolean
|
||||
}
|
||||
|
||||
/** Options for {@link sweepSpillRoots} — the roots to scan, the age cutoff, and a failure sink. */
|
||||
export interface SweepOptions {
|
||||
/** Absolute spill roots to sweep (configured root and/or discovered default roots). */
|
||||
roots: string[]
|
||||
/** Roots to sweep (configured/active root and/or discovered prior-default roots). */
|
||||
roots: SweepRoot[]
|
||||
/**
|
||||
* Epoch-millis cutoff: a regular file is deleted when its `mtime` is strictly
|
||||
* older than this. The caller derives it from `now - cleanupPeriodDays`, so a
|
||||
@@ -178,12 +208,14 @@ export function isErrno(error: unknown, code: string): boolean {
|
||||
/**
|
||||
* Sweep one spill session directory: delete expired regular files, skip
|
||||
* everything else, and report the directory empty afterward so the caller can
|
||||
* prune it. A symlink or any non-regular entry (socket, fifo, nested dir) is
|
||||
* left untouched — `lstat` never follows a link, so a planted symlink can
|
||||
* neither be deleted nor redirect the age check. Every per-entry failure is
|
||||
* prune it. The `dir` entry MUST be a real directory — the caller `lstat`s it
|
||||
* first and skips a symlink, so this never follows a `session-*` symlink into a
|
||||
* foreign tree. Inside, a symlink or any non-regular entry (socket, fifo, nested
|
||||
* dir) is left untouched — `lstat` never follows a link, so a planted symlink
|
||||
* can neither be deleted nor redirect the age check. Every per-entry failure is
|
||||
* contained: one unreadable file does not abort the directory.
|
||||
*
|
||||
* @param dir The absolute session directory to scan.
|
||||
* @param dir The absolute session directory to scan (already confirmed a real dir).
|
||||
* @param cutoffMs Files with `mtime` strictly older than this are deleted.
|
||||
* @param warn Sink for contained filesystem failures.
|
||||
* @returns `true` when the directory holds no entries after the sweep (a prune candidate).
|
||||
@@ -241,20 +273,39 @@ export async function sweepSpillRoots(options: SweepOptions): Promise<void> {
|
||||
for (const root of roots) {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await readdir(root)
|
||||
entries = await readdir(root.path)
|
||||
} catch (error: unknown) {
|
||||
// A root that does not exist yet (no spill ever written) is the common
|
||||
// case, not an error: ENOENT is silent, anything else is reported.
|
||||
if (!isErrno(error, 'ENOENT')) warn(`spill-local: failed to read root ${root}: ${String(error)}`)
|
||||
if (!isErrno(error, 'ENOENT')) warn(`spill-local: failed to read root ${root.path}: ${String(error)}`)
|
||||
continue
|
||||
}
|
||||
// Track whether the root holds ANY entry the sweep did not fully reclaim, so
|
||||
// a discovered prior-default root can be pruned only when nothing remains.
|
||||
let rootEmptiable = true
|
||||
for (const name of entries) {
|
||||
// Only the backend's own `session-<hash>` directories are swept; an
|
||||
// unrelated sibling under a shared configured root is left untouched.
|
||||
if (!name.startsWith('session-')) continue
|
||||
const dir = join(root, name)
|
||||
// Only the backend's own `session-<12 hex>` directories are swept; an
|
||||
// unrelated sibling (`session-backup`, a stray file) is left untouched and
|
||||
// blocks pruning the root.
|
||||
if (!SESSION_DIR_RE.test(name)) { rootEmptiable = false; continue }
|
||||
const dir = join(root.path, name)
|
||||
let stats
|
||||
try {
|
||||
// lstat the session entry itself: a `session-*` SYMLINK must never be
|
||||
// followed (readdir/unlink through it would delete files in a foreign
|
||||
// target). Only a real directory is swept.
|
||||
stats = await lstat(dir)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore start -- an entry readdir just returned fails to lstat only
|
||||
by racing away (ENOENT) or a permission/IO fault; not deterministically
|
||||
reproducible. */
|
||||
if (!isErrno(error, 'ENOENT')) warn(`spill-local: failed to stat ${dir}: ${String(error)}`)
|
||||
continue
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
if (!stats.isDirectory()) { rootEmptiable = false; continue }
|
||||
const empty = await sweepSessionDir(dir, cutoffMs, warn)
|
||||
if (!empty) continue
|
||||
if (!empty) { rootEmptiable = false; continue }
|
||||
try {
|
||||
await rmdir(dir)
|
||||
} catch (error: unknown) {
|
||||
@@ -262,22 +313,42 @@ export async function sweepSpillRoots(options: SweepOptions): Promise<void> {
|
||||
here means a concurrent writer added a file (ENOTEMPTY) or a
|
||||
permission/IO fault struck — both are races outside deterministic
|
||||
in-process testing. */
|
||||
rootEmptiable = false
|
||||
if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) {
|
||||
warn(`spill-local: failed to prune ${dir}: ${String(error)}`)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
}
|
||||
// A discovered prior-default root (one per past process) is removed once its
|
||||
// last session dir is gone — otherwise empty roots accumulate forever and
|
||||
// every future startup rescans them. The active/configured root is never
|
||||
// pruned (the live process is still writing into it).
|
||||
if (root.pruneWhenEmpty && rootEmptiable) {
|
||||
try {
|
||||
await rmdir(root.path)
|
||||
} catch (error: unknown) {
|
||||
// A concurrent process may have written a fresh spill into this root
|
||||
// after our scan (ENOTEMPTY), or removed it already (ENOENT) — benign
|
||||
// races. Anything else is reported.
|
||||
if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) {
|
||||
warn(`spill-local: failed to prune root ${root.path}: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover prior default spill roots: the `dsh-spill-*` directories directly
|
||||
* under `base` (the OS tmpdir) that earlier runs created via {@link privateRoot}
|
||||
* when no `root` was configured. A long-lived deployment with a configured root
|
||||
* will find none; a series of default-root runs accumulates one per process, so
|
||||
* the startup sweep reclaims them all. Symlinks and non-directories are excluded
|
||||
* — only real directories the backend could have created are returned.
|
||||
* Discover prior default spill roots: the `dsh-spill-<6 chars>` directories
|
||||
* directly under `base` (the OS tmpdir) that earlier runs created via
|
||||
* {@link privateRoot} when no `root` was configured. A long-lived deployment
|
||||
* with a configured root will find none; a series of default-root runs
|
||||
* accumulates one per process, so the startup sweep reclaims them all. Matching
|
||||
* is the EXACT `mkdtemp` shape (see {@link DEFAULT_ROOT_RE}), not the bare
|
||||
* prefix, so an unrelated `dsh-spill-test-*` fixture or a foreign
|
||||
* differently-shaped directory is never swept; symlinks and non-directories are
|
||||
* excluded too — only real directories the backend could have created.
|
||||
*
|
||||
* @param warn Sink for a failure reading `base` (returns `[]` on failure).
|
||||
* @param base The directory to scan; defaults to the OS tmpdir (a test seam).
|
||||
@@ -293,7 +364,7 @@ export async function discoverDefaultRoots(warn: WarnFn, base: string = tmpdir()
|
||||
}
|
||||
const roots: string[] = []
|
||||
for (const name of entries) {
|
||||
if (!name.startsWith(DEFAULT_ROOT_PREFIX)) continue
|
||||
if (!DEFAULT_ROOT_RE.test(name)) continue
|
||||
const path = join(base, name)
|
||||
let stats
|
||||
try {
|
||||
|
||||
@@ -27,6 +27,7 @@ import LocalSpillStore, {
|
||||
sessionDir,
|
||||
sweepSpillRoots,
|
||||
} from '@deepseek-ai/dsh-spill-local'
|
||||
import type { SweepRoot } from '@deepseek-ai/dsh-spill-local'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -196,7 +197,7 @@ describe('LocalSpillStore service', () => {
|
||||
// default here (scan the OS tmpdir) without letting the sweep touch tmpdir.
|
||||
class Exposed extends LocalSpillStore {
|
||||
base(): string { return this.defaultRootsBase() }
|
||||
protected override async gatherRoots(): Promise<string[]> { return [] }
|
||||
protected override async gatherRoots(): Promise<SweepRoot[]> { return [] }
|
||||
}
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(Exposed, { root, cleanupPeriodDays: 30 })
|
||||
@@ -206,18 +207,18 @@ describe('LocalSpillStore service', () => {
|
||||
})
|
||||
|
||||
it('routes a sweep filesystem failure to ctx.logger.warn (service warn wiring)', async () => {
|
||||
// A `session-*` entry that is a FILE, not a directory, makes readdir throw
|
||||
// ENOTDIR inside the real sweep. The service's warn closure must forward it
|
||||
// to ctx.logger.warn, and disposal must still settle cleanly.
|
||||
const stray = join(root, 'session-stray'); writeFileSync(stray, 'x')
|
||||
// A root that is a FILE, not a directory, makes readdir throw ENOTDIR inside
|
||||
// the real sweep. The service's warn closure must forward it to
|
||||
// ctx.logger.warn, and disposal must still settle cleanly.
|
||||
const filePath = join(root, 'not-a-dir'); writeFileSync(filePath, 'x')
|
||||
const ctx = new Context()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
class Discovering extends LocalSpillStore {
|
||||
protected override async gatherRoots(): Promise<string[]> { return [this.root] }
|
||||
protected override async gatherRoots(): Promise<SweepRoot[]> { return [{ path: this.root, pruneWhenEmpty: false }] }
|
||||
}
|
||||
const fiber = await ctx.plugin(Discovering, { root, cleanupPeriodDays: 30 })
|
||||
const fiber = await ctx.plugin(Discovering, { root: filePath, cleanupPeriodDays: 30 })
|
||||
await fiber.dispose()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read root'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -227,15 +228,16 @@ describe('LocalSpillStore service', () => {
|
||||
* gather open so a test can prove disposal awaits the sweep.
|
||||
*/
|
||||
class SweptStore extends LocalSpillStore {
|
||||
static sweepRoots: string[] = []
|
||||
static sweepRoots: SweepRoot[] = []
|
||||
static barrier: Promise<void> | undefined
|
||||
protected override async gatherRoots(): Promise<string[]> {
|
||||
protected override async gatherRoots(): Promise<SweepRoot[]> {
|
||||
if (SweptStore.barrier) await SweptStore.barrier
|
||||
return SweptStore.sweepRoots
|
||||
}
|
||||
}
|
||||
|
||||
async function runSweep(roots: string[], cleanupPeriodDays = 30): Promise<void> {
|
||||
/** Sweep the given roots via the fiber-owned startup sweep; `root` is the active (non-pruned) root. */
|
||||
async function runSweep(roots: SweepRoot[], cleanupPeriodDays = 30): Promise<void> {
|
||||
SweptStore.sweepRoots = roots
|
||||
SweptStore.barrier = undefined
|
||||
const ctx = new Context()
|
||||
@@ -244,13 +246,18 @@ async function runSweep(roots: string[], cleanupPeriodDays = 30): Promise<void>
|
||||
await fiber.dispose()
|
||||
}
|
||||
|
||||
/** The active configured root as a non-pruned sweep target (the common single-root case). */
|
||||
function active(path: string): SweepRoot {
|
||||
return { path, pruneWhenEmpty: false }
|
||||
}
|
||||
|
||||
describe('startup cleanup sweep', () => {
|
||||
it('deletes files older than the cutoff and keeps fresh ones', async () => {
|
||||
const dir = sessionDir(root, 'sess-1')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const old = join(dir, 'old.txt'); writeAged(old, 'x', 40)
|
||||
const fresh = join(dir, 'fresh.txt'); writeAged(fresh, 'y', 1)
|
||||
await runSweep([root])
|
||||
await runSweep([active(root)])
|
||||
expect(existsSync(old)).toBe(false)
|
||||
expect(existsSync(fresh)).toBe(true)
|
||||
})
|
||||
@@ -261,7 +268,7 @@ describe('startup cleanup sweep', () => {
|
||||
// mtime == cutoff: mtimeMs >= cutoffMs holds, so it is kept. Age it just
|
||||
// under 30d to avoid the sub-millisecond race of "exactly now - 30d".
|
||||
const boundary = join(dir, 'boundary.txt'); writeAged(boundary, 'x', 29.9)
|
||||
await runSweep([root])
|
||||
await runSweep([active(root)])
|
||||
expect(existsSync(boundary)).toBe(true)
|
||||
})
|
||||
|
||||
@@ -269,23 +276,23 @@ describe('startup cleanup sweep', () => {
|
||||
const dir = sessionDir(root, 'sess-1')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const old = join(dir, 'old.txt'); writeAged(old, 'x', 400)
|
||||
await runSweep([root], 0)
|
||||
await runSweep([active(root)], 0)
|
||||
expect(existsSync(old)).toBe(true)
|
||||
})
|
||||
|
||||
it('prunes a directory left empty, keeps one with a surviving file', async () => {
|
||||
it('prunes a session directory left empty, keeps one with a surviving file', async () => {
|
||||
const emptied = sessionDir(root, 'emptied')
|
||||
const kept = sessionDir(root, 'kept')
|
||||
mkdirSync(emptied, { recursive: true })
|
||||
mkdirSync(kept, { recursive: true })
|
||||
writeAged(join(emptied, 'a.txt'), 'x', 40)
|
||||
writeAged(join(kept, 'fresh.txt'), 'y', 1)
|
||||
await runSweep([root])
|
||||
await runSweep([active(root)])
|
||||
expect(existsSync(emptied)).toBe(false)
|
||||
expect(existsSync(kept)).toBe(true)
|
||||
})
|
||||
|
||||
it('skips symlinks and non-session entries', async () => {
|
||||
it('skips a symlink INSIDE a session dir and non-session siblings', async () => {
|
||||
const dir = sessionDir(root, 'sess-1')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
// A symlink pointing at an old target must NOT be followed or deleted.
|
||||
@@ -294,7 +301,7 @@ describe('startup cleanup sweep', () => {
|
||||
// A non-session sibling directory under a shared root is untouched.
|
||||
const unrelated = join(root, 'not-a-session'); mkdirSync(unrelated)
|
||||
const unrelatedOld = join(unrelated, 'old.txt'); writeAged(unrelatedOld, 'x', 40)
|
||||
await runSweep([root])
|
||||
await runSweep([active(root)])
|
||||
// The symlink itself survives (lstat sees a link, not a file), so its dir is
|
||||
// not empty and is not pruned; the link target survives too.
|
||||
expect(existsSync(link)).toBe(true)
|
||||
@@ -302,12 +309,73 @@ describe('startup cleanup sweep', () => {
|
||||
expect(existsSync(unrelatedOld)).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT follow a symlinked session directory (no deletion in the target)', async () => {
|
||||
// A `session-<12hex>`-NAMED symlink pointing at a directory of old files must
|
||||
// never be descended: lstat on the entry sees a link, so the target's files
|
||||
// are left intact and the link itself is not removed.
|
||||
const victimDir = join(root, 'victim'); mkdirSync(victimDir, { recursive: true })
|
||||
const victimOld = join(victimDir, 'old.txt'); writeAged(victimOld, 'x', 40)
|
||||
const linkName = `session-${'a'.repeat(12)}`
|
||||
const link = join(root, linkName); symlinkSync(victimDir, link)
|
||||
await runSweep([active(root)])
|
||||
expect(existsSync(victimOld)).toBe(true)
|
||||
expect(existsSync(link)).toBe(true)
|
||||
})
|
||||
|
||||
it('sweeps only exact session-<12hex> names, not lookalikes', async () => {
|
||||
// `session-backup` and `session-<11hex>` match the old startsWith check but
|
||||
// are NOT backend-generated names; their old files must survive.
|
||||
const backup = join(root, 'session-backup'); mkdirSync(backup, { recursive: true })
|
||||
const backupOld = join(backup, 'old.txt'); writeAged(backupOld, 'x', 40)
|
||||
const shortHex = join(root, `session-${'a'.repeat(11)}`); mkdirSync(shortHex, { recursive: true })
|
||||
const shortOld = join(shortHex, 'old.txt'); writeAged(shortOld, 'x', 40)
|
||||
// A real session dir alongside them IS swept, proving the sweep still runs.
|
||||
const real = sessionDir(root, 'sess-1'); mkdirSync(real, { recursive: true })
|
||||
const realOld = join(real, 'old.txt'); writeAged(realOld, 'x', 40)
|
||||
await runSweep([active(root)])
|
||||
expect(existsSync(backupOld)).toBe(true)
|
||||
expect(existsSync(shortOld)).toBe(true)
|
||||
expect(existsSync(realOld)).toBe(false)
|
||||
})
|
||||
|
||||
it('prunes an emptied DISCOVERED default root but never the active root', async () => {
|
||||
// A discovered prior-default root (pruneWhenEmpty) whose only session dir is
|
||||
// emptied should have its outer directory removed too; the active root, even
|
||||
// when fully emptied, must survive (the live process still writes into it).
|
||||
const prior = mkdtempSync(join(tmpdir(), 'dsh-spill-'))
|
||||
const priorDir = sessionDir(prior, 'old-sess'); mkdirSync(priorDir, { recursive: true })
|
||||
writeAged(join(priorDir, 'old.txt'), 'x', 40)
|
||||
const activeDir = sessionDir(root, 'sess-1'); mkdirSync(activeDir, { recursive: true })
|
||||
writeAged(join(activeDir, 'old.txt'), 'x', 40)
|
||||
try {
|
||||
await runSweep([{ path: prior, pruneWhenEmpty: true }, active(root)])
|
||||
expect(existsSync(prior)).toBe(false) // discovered root pruned
|
||||
expect(existsSync(root)).toBe(true) // active root kept
|
||||
expect(existsSync(activeDir)).toBe(false) // its emptied session dir still pruned
|
||||
} finally {
|
||||
rmSync(prior, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does NOT prune a discovered root that still holds a fresh file', async () => {
|
||||
const prior = mkdtempSync(join(tmpdir(), 'dsh-spill-'))
|
||||
const priorDir = sessionDir(prior, 'sess'); mkdirSync(priorDir, { recursive: true })
|
||||
writeAged(join(priorDir, 'fresh.txt'), 'y', 1)
|
||||
try {
|
||||
await runSweep([{ path: prior, pruneWhenEmpty: true }])
|
||||
expect(existsSync(prior)).toBe(true)
|
||||
expect(existsSync(priorDir)).toBe(true)
|
||||
} finally {
|
||||
rmSync(prior, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('covers the configured root AND discovered default roots (real gatherRoots)', async () => {
|
||||
// A prior default root under an isolated fake tmpdir + the configured root.
|
||||
// This test drives the REAL gatherRoots/discoverDefaultRoots path by seaming
|
||||
// only the tmpdir scan base, not gatherRoots itself.
|
||||
const fakeTmp = mkdtempSync(join(tmpdir(), 'dsh-faketmp-'))
|
||||
const priorDefault = join(fakeTmp, `${DEFAULT_ROOT_PREFIX}ABCDEF`)
|
||||
const priorDefault = mkdtempSync(join(fakeTmp, DEFAULT_ROOT_PREFIX))
|
||||
const priorDir = sessionDir(priorDefault, 'old-sess')
|
||||
mkdirSync(priorDir, { recursive: true })
|
||||
const priorOld = join(priorDir, 'old.txt'); writeAged(priorOld, 'x', 40)
|
||||
@@ -323,6 +391,9 @@ describe('startup cleanup sweep', () => {
|
||||
await fiber.dispose()
|
||||
expect(existsSync(priorOld)).toBe(false)
|
||||
expect(existsSync(cfgOld)).toBe(false)
|
||||
// The discovered prior-default root is pruned; the configured root is kept.
|
||||
expect(existsSync(priorDefault)).toBe(false)
|
||||
expect(existsSync(root)).toBe(true)
|
||||
} finally {
|
||||
rmSync(fakeTmp, { recursive: true, force: true })
|
||||
}
|
||||
@@ -330,10 +401,11 @@ describe('startup cleanup sweep', () => {
|
||||
|
||||
it('de-dups when the active root is itself a discovered default (real gatherRoots)', async () => {
|
||||
// The configured root lives directly under the seamed base and matches the
|
||||
// default prefix, so discovery finds it AND it is the active root — the sweep
|
||||
// must run once, not choke on the duplicate.
|
||||
// default shape, so discovery finds it AND it is the active root — the sweep
|
||||
// must run once, not choke on the duplicate, and must NOT prune the active
|
||||
// root even though discovery would otherwise mark a default root prunable.
|
||||
const fakeTmp = mkdtempSync(join(tmpdir(), 'dsh-faketmp-'))
|
||||
const activeDefault = join(fakeTmp, `${DEFAULT_ROOT_PREFIX}ACTIVE`)
|
||||
const activeDefault = mkdtempSync(join(fakeTmp, DEFAULT_ROOT_PREFIX))
|
||||
const dir = sessionDir(activeDefault, 'sess-1')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const old = join(dir, 'old.txt'); writeAged(old, 'x', 40)
|
||||
@@ -345,6 +417,8 @@ describe('startup cleanup sweep', () => {
|
||||
const fiber = await ctx.plugin(Discovering, { root: activeDefault, cleanupPeriodDays: 30 })
|
||||
await fiber.dispose()
|
||||
expect(existsSync(old)).toBe(false)
|
||||
// Active root survives even though its name matches the discovered shape.
|
||||
expect(existsSync(activeDefault)).toBe(true)
|
||||
} finally {
|
||||
rmSync(fakeTmp, { recursive: true, force: true })
|
||||
}
|
||||
@@ -357,7 +431,7 @@ describe('startup cleanup sweep', () => {
|
||||
|
||||
// Hold the sweep open behind a barrier we control.
|
||||
let release!: () => void
|
||||
SweptStore.sweepRoots = [root]
|
||||
SweptStore.sweepRoots = [active(root)]
|
||||
SweptStore.barrier = new Promise<void>((resolve) => { release = resolve })
|
||||
|
||||
const ctx = new Context()
|
||||
@@ -380,35 +454,28 @@ describe('startup cleanup sweep', () => {
|
||||
// A path that is a FILE, not a directory: readdir(root) throws ENOTDIR. The
|
||||
// sweep must log and return, never reject.
|
||||
const filePath = join(root, 'not-a-dir'); writeFileSync(filePath, 'x')
|
||||
await expect(sweepSpillRoots({ roots: [filePath], cutoffMs: Date.now(), warn })).resolves.toBeUndefined()
|
||||
await expect(sweepSpillRoots({ roots: [active(filePath)], cutoffMs: Date.now(), warn })).resolves.toBeUndefined()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read root'))
|
||||
})
|
||||
|
||||
it('a nonexistent root is silent (the common no-spill-yet case)', async () => {
|
||||
const warn = vi.fn()
|
||||
await sweepSpillRoots({ roots: [join(root, 'never-created')], cutoffMs: Date.now(), warn })
|
||||
await sweepSpillRoots({ roots: [active(join(root, 'never-created'))], cutoffMs: Date.now(), warn })
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a session entry that is a file (not a dir) is reported, not pruned', async () => {
|
||||
const warn = vi.fn()
|
||||
// `session-strayfile` matches the session- prefix but is a regular file, so
|
||||
// readdir on it throws ENOTDIR: reported, left in place (not empty → no prune).
|
||||
const stray = join(root, 'session-strayfile'); writeFileSync(stray, 'x')
|
||||
await sweepSpillRoots({ roots: [root], cutoffMs: Date.now(), warn })
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read'))
|
||||
expect(existsSync(stray)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('discoverDefaultRoots', () => {
|
||||
it('returns only real dsh-spill-* directories, excluding symlinks and non-matches', async () => {
|
||||
const base = mkdtempSync(join(tmpdir(), 'dsh-disc-'))
|
||||
try {
|
||||
const realRoot = join(base, `${DEFAULT_ROOT_PREFIX}real`); mkdirSync(realRoot)
|
||||
// A real backend-shaped root (dsh-spill-<6>) via mkdtemp — the only match.
|
||||
const realRoot = mkdtempSync(join(base, DEFAULT_ROOT_PREFIX))
|
||||
mkdirSync(join(base, 'unrelated-dir'))
|
||||
writeFileSync(join(base, `${DEFAULT_ROOT_PREFIX}file`), 'x') // matches prefix but is a file
|
||||
symlinkSync(realRoot, join(base, `${DEFAULT_ROOT_PREFIX}link`)) // matches prefix but is a symlink
|
||||
// Names of the EXACT default shape that must still be excluded because they
|
||||
// are not real directories the backend could have created.
|
||||
writeFileSync(join(base, `${DEFAULT_ROOT_PREFIX}file01`), 'x') // matches shape but is a file
|
||||
symlinkSync(realRoot, join(base, `${DEFAULT_ROOT_PREFIX}link01`)) // matches shape but is a symlink
|
||||
const found = await discoverDefaultRoots(() => {}, base)
|
||||
expect(found).toEqual([realRoot])
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user