diff --git a/packages/spill/spill-local/src/index.ts b/packages/spill/spill-local/src/index.ts index 33948712f0..7c4069c539 100644 --- a/packages/spill/spill-local/src/index.ts +++ b/packages/spill/spill-local/src/index.ts @@ -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 { + protected async gatherRoots(warn: WarnFn): Promise { 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 } /** diff --git a/packages/spill/spill-local/src/store.ts b/packages/spill/spill-local/src/store.ts index 431e4840c6..01528fd573 100644 --- a/packages/spill/spill-local/src/store.ts +++ b/packages/spill/spill-local/src/store.ts @@ -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 /** 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 { 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-` 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 { 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 { diff --git a/packages/spill/spill-local/tests/spill-local.spec.ts b/packages/spill/spill-local/tests/spill-local.spec.ts index 8e632c885d..41d35824c6 100644 --- a/packages/spill/spill-local/tests/spill-local.spec.ts +++ b/packages/spill/spill-local/tests/spill-local.spec.ts @@ -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 { return [] } + protected override async gatherRoots(): Promise { 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 { return [this.root] } + protected override async gatherRoots(): Promise { 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 | undefined - protected override async gatherRoots(): Promise { + protected override async gatherRoots(): Promise { if (SweptStore.barrier) await SweptStore.barrier return SweptStore.sweepRoots } } -async function runSweep(roots: string[], cleanupPeriodDays = 30): Promise { +/** 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 { SweptStore.sweepRoots = roots SweptStore.barrier = undefined const ctx = new Context() @@ -244,13 +246,18 @@ async function runSweep(roots: string[], cleanupPeriodDays = 30): Promise 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((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 {