diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml index 5ba89a4412..20f4e54eae 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md -2026-07-08-tool-output-spill-files.md: 14667b74ca877622d05196e9bf83945a842fe366 -2026-07-08-tool-output-spill-files.zh.md: db297fa6bee707a1d5a10d20260ce6b8a660d207 +2026-07-08-tool-output-spill-files.md: 81a292a00af63b0aa9145a0c556a428ab8c49d64 +2026-07-08-tool-output-spill-files.zh.md: 8d9e60d461297fb11ff2252e91f98a0cfad33f62 diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md index 6164066dbb..81a292a00a 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -161,7 +161,7 @@ Those cases can consume `ctx.spillStore` directly in later work. They are not pa - Per-tool opt-out or per-tool policy declarations if the built-in `read` skip is insufficient. - Remote or database storage backends for ACP or remote environments where a local path is not meaningful. -Cleanup shipped for the local backend as a one-shot startup sweep, not tied to session deletion — see the [startup-cleanup RFC](./2026-07-17-local-spill-startup-cleanup.md). The seam still defines no per-session cleanup policy; retention is a backend concern. +Cleanup shipped for the local backend as a one-shot startup sweep, not tied to session deletion — see the [startup-cleanup Agent Note](./2026-07-17-local-spill-startup-cleanup.md). The seam still defines no per-session cleanup policy; retention is a backend concern. ## Testing diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md index db297fa6be..8d9e60d461 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md @@ -160,7 +160,8 @@ ctx.tools.register(defineTool({ - 由工具负责的 subagent 执行轨迹 spill(`await run.result`,在 `run.dispose()` 前读取进程内子会话,保存 JSONL)。 - 如果内置的 `read` 跳过规则不足,再增加逐工具选择退出或逐工具策略声明。 - 面向 ACP(Agent Client Protocol)或远程环境的远程/数据库存储后端,因为本地路径在这些环境中没有意义。 -- 旧 spill 文件的清理和保留策略,很可能与会话清理绑定。 + +本地后端通过一次性启动扫描清理旧文件,而不是绑定到会话删除——参见[启动清理 Agent Note](./2026-07-17-local-spill-startup-cleanup.zh.md)。seam 仍未定义逐会话清理策略;保留策略属于后端。 ## 测试 diff --git a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml index 97d2b5426b..511dda5a74 100644 --- a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-17-local-spill-startup-cleanup.md: ca4931776f89e641f127072f665e238ca2a1600d -2026-07-17-local-spill-startup-cleanup.zh.md: b90923844ab71e1ce570e8adb66f81aed7bc3488 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md +2026-07-17-local-spill-startup-cleanup.md: 96378d6ea785d90385f517c1b9a01073ade47fa2 +2026-07-17-local-spill-startup-cleanup.zh.md: a154cfb824d3a747c2c9acc2b707eb14d703ce2e diff --git a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md index ca4931776f..96378d6ea7 100644 --- a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md +++ b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md @@ -12,9 +12,9 @@ The local spill backend never deleted the full tool results it wrote. Every over `dsh-spill-local` runs one best-effort cleanup sweep after activation. It does not delay service availability, is owned by the plugin fiber (a single `ctx.effect` whose generator launches the sweep and yields an async disposer that awaits it), and is awaited during disposal so no sweep I/O outlives the fiber. There is no recurring timer and no separate process. -A `cleanupPeriodDays` config defaults to `30`; `0` disables cleanup. An invalid value (negative or fractional) throws at load. The sweep scans the configured/active root plus any prior default `dsh-spill-*` temp roots discovered under the OS temp dir, deletes regular files whose `mtime` is strictly older than `now − cleanupPeriodDays`, and prunes directories left empty. It uses `lstat`, so a symlink is never followed or deleted; unrelated entries (non-`session-` directories, special files) are skipped. Every filesystem failure is caught and logged through `ctx.logger.warn` — the sweep never throws, so it cannot reject activation or a concurrent spill write. Discovery excludes symlinks and non-directories, returning only real `dsh-spill-*` directories the backend could have created. +A `cleanupPeriodDays` config defaults to `30`; `0` disables cleanup. An invalid value (negative or fractional) throws at load. The sweep scans the configured/active root plus any prior default `dsh-spill-*` temp roots discovered under the OS temp dir and deletes regular files whose `mtime` is strictly older than `now − cleanupPeriodDays`. It prunes empty session directories and roots only for discovered prior-default roots; the active root keeps its session directories so pruning cannot race a local write, while writes recreate a session directory if another process prunes a discovered root that is still active. It uses `lstat`, so a symlink is never followed or deleted; unrelated entries (non-`session-` directories, special files) are skipped. Every filesystem failure is caught and logged through `ctx.logger.warn`, and a warning-sink exception is also contained — the sweep never throws, so it cannot reject activation or a concurrent spill write. Discovery excludes symlinks and non-directories, returning only real `dsh-spill-*` directories the backend could have created. -The ctx-free mechanics live in `packages/spill/spill-local/src/store.ts` (`sweepSpillRoots`, `discoverDefaultRoots`, `DEFAULT_ROOT_PREFIX`, `isErrno`), unit-testable without a `ctx`; the service in `src/index.ts` owns the config, the cutoff, and the fiber-owned launch/await. +The ctx-free sweep mechanics live in `packages/spill/spill-local/src/cleanup.ts` (`sweepSpillRoots`, `discoverDefaultRoots`), unit-testable without a `ctx`; `store.ts` owns root naming, path derivation, and writes, while the service in `src/index.ts` owns the config, cutoff, and fiber-owned launch/await. ## Alternatives considered @@ -32,4 +32,4 @@ Cleanup cost the backend a startup sweep and a config knob, and bought a bounded ## Testing -`dsh-spill-local` unit tests cover the age boundary (strictly-older expires, boundary kept), `cleanupPeriodDays: 0` disabling, empty-directory pruning, symlink/unrelated-entry skipping, configured-plus-discovered-root coverage through the real `gatherRoots`/`discoverDefaultRoots` path, active-root de-duplication, load-time validation of a bad `cleanupPeriodDays`, filesystem-failure containment (logged, not thrown) both directly and through the service's `ctx.logger.warn` wiring, and the quiescence contract — activation is available while a barrier-held sweep is parked, and disposal only settles after the sweep finishes. +`dsh-spill-local` unit tests cover the age boundary (strictly-older expires, boundary kept), `cleanupPeriodDays: 0` disabling, discovered-root pruning, active-directory preservation, symlink/unrelated-entry skipping, configured-plus-discovered-root coverage through the real `gatherRoots`/`discoverDefaultRoots` path, active-root de-duplication, load-time validation of a bad `cleanupPeriodDays`, filesystem- and warning-sink-failure containment both directly and through the service's `ctx.logger.warn` wiring, and the quiescence contract — activation is available while a barrier-held sweep is parked, and disposal only settles after the sweep finishes. diff --git a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md index b90923844a..a154cfb824 100644 --- a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -本地 spill 后端从不删除它写下的完整工具结果。每个超限结果都会新增一个文件,因此配置的根目录会无限增长,而每进程默认的 `dsh-spill-*` 根目录也会跨多次运行不断累积。立即删除是错误的,因为已持久化、已恢复和已 fork 的会话仍可能引用某个 locator。[工具输出 spill 策略](./2026-07-08-tool-output-spill-files.md)需要一个有界的本地存储生命周期。 +本地 spill 后端从不删除它写下的完整工具结果。每个超限结果都会新增一个文件,因此配置的根目录会无限增长,而每进程默认的 `dsh-spill-*` 根目录也会跨多次运行不断累积。立即删除是错误的,因为已持久化、已恢复和已 fork 的会话仍可能引用某个 locator。[工具输出 spill 策略](./2026-07-08-tool-output-spill-files.zh.md)需要一个有界的本地存储生命周期。 ## 决策 `dsh-spill-local` 在激活后运行一次尽力而为的清理扫描。它不延迟服务可用性,由插件 fiber 拥有(一个 `ctx.effect`,其生成器启动该扫描并让出一个等待它的异步 disposer),并在 dispose 期间被等待,因此没有扫描 I/O 会存活到 fiber 之后。既没有周期性定时器,也没有独立进程。 -`cleanupPeriodDays` 配置默认为 `30`;`0` 会禁用清理。无效值(负数或小数)在加载时抛出。扫描会遍历配置的/活动的根目录,以及在 OS 临时目录下发现的任何先前默认 `dsh-spill-*` 临时根目录,删除 `mtime` 严格早于 `now − cleanupPeriodDays` 的常规文件,并修剪清空后的目录。它使用 `lstat`,因此符号链接绝不会被跟随或删除;无关条目(非 `session-` 目录、特殊文件)会被跳过。每一次文件系统失败都会被捕获并通过 `ctx.logger.warn` 记录——扫描绝不抛出,因此它无法让激活失败,也无法影响并发的 spill 写入。发现过程排除符号链接与非目录,只返回后端可能创建过的真实 `dsh-spill-*` 目录。 +`cleanupPeriodDays` 配置默认为 `30`;`0` 会禁用清理。无效值(负数或小数)在加载时抛出。扫描会遍历配置的/活动的根目录,以及在 OS 临时目录下发现的任何先前默认 `dsh-spill-*` 临时根目录,并删除 `mtime` 严格早于 `now − cleanupPeriodDays` 的常规文件。它只修剪发现的先前默认根目录中的空会话目录和空根目录;活动根目录会保留其会话目录,避免修剪操作与本地写入竞争,而当其他进程修剪了一个仍在使用的发现根目录时,写入操作会重新创建会话目录。扫描使用 `lstat`,因此符号链接绝不会被跟随或删除;无关条目(非 `session-` 目录、特殊文件)会被跳过。每一次文件系统失败都会被捕获并通过 `ctx.logger.warn` 记录,警告接收方抛出的异常也会被兜底——扫描绝不抛出,因此它无法让激活失败,也无法影响并发的 spill 写入。发现过程排除符号链接与非目录,只返回后端可能创建过的真实 `dsh-spill-*` 目录。 -无 ctx 依赖的机制位于 `packages/spill/spill-local/src/store.ts`(`sweepSpillRoots`、`discoverDefaultRoots`、`DEFAULT_ROOT_PREFIX`、`isErrno`),无需 `ctx` 即可做单元测试;`src/index.ts` 中的服务负责配置、截止时间以及 fiber 拥有的启动/等待。 +无 ctx 依赖的扫描机制位于 `packages/spill/spill-local/src/cleanup.ts`(`sweepSpillRoots`、`discoverDefaultRoots`),无需 `ctx` 即可做单元测试;`store.ts` 负责根目录命名、路径推导与写入,而 `src/index.ts` 中的服务负责配置、截止时间以及 fiber 拥有的启动/等待。 ## 考虑过的替代方案 @@ -32,4 +32,4 @@ Status: implemented ## 验证 -`dsh-spill-local` 单元测试覆盖了年龄边界(严格更旧者过期,边界值保留)、`cleanupPeriodDays: 0` 的禁用、空目录修剪、符号链接/无关条目的跳过、通过真实 `gatherRoots`/`discoverDefaultRoots` 路径对配置根加发现根的覆盖、活动根去重、对错误 `cleanupPeriodDays` 的加载期校验、文件系统失败的兜底(记录而非抛出)——既直接测试,也经由服务的 `ctx.logger.warn` 接线测试——以及静止契约:在一个被屏障挂起的扫描停驻期间激活仍然可用,而 dispose 只有在扫描结束后才会完成。 +`dsh-spill-local` 单元测试覆盖了年龄边界(严格更旧者过期,边界值保留)、`cleanupPeriodDays: 0` 的禁用、发现根目录的修剪、活动目录的保留、符号链接/无关条目的跳过、通过真实 `gatherRoots`/`discoverDefaultRoots` 路径对配置根加发现根的覆盖、活动根去重、对错误 `cleanupPeriodDays` 的加载期校验、直接测试以及经由服务的 `ctx.logger.warn` 接线测试所覆盖的文件系统与警告接收方失败兜底,以及静止契约:在一个被屏障挂起的扫描停驻期间激活仍然可用,而 dispose 只有在扫描结束后才会完成。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index aed3574106..66d5592ba2 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: a845fe22e13ed085765668c7ec8d54d6bbdf129a -config-catalog.zh.md: 39ba9d48368f99483733292f997609ba3a8aa43e +config-catalog.md: 9fc8c333510c568686ce43f4aa1f6d0ae6bc3615 +config-catalog.zh.md: 4fb689f63631740d485a854e10a12c6d92c6f4ac diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ac7c00738c..9fc8c33351 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2074,7 +2074,7 @@ export interface Config { } ``` -Source: [`packages/spill/spill-local/src/index.ts:28`](../packages/spill/spill-local/src/index.ts) +Source: [`packages/spill/spill-local/src/index.ts:31`](../packages/spill/spill-local/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 39ba9d4836..4fb689f636 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2064,10 +2064,19 @@ export interface Config { * a local deployment. Set it to keep spill files under a known location. */ root?: string + /** + * Age in days after which a spill file is eligible for the one-shot startup + * cleanup sweep. Defaults to `30`; `0` disables cleanup entirely. Files whose + * `mtime` is strictly older than the cutoff are deleted and emptied + * directories are pruned; fresh files, symlinks, and unrelated entries are + * left untouched. Retention is deliberate — a resumed or forked session may + * still reference an older locator until it ages out. + */ + cleanupPeriodDays?: number } ``` -来源:[`packages/spill/spill-local/src/index.ts:22`](../packages/spill/spill-local/src/index.ts) +来源:[`packages/spill/spill-local/src/index.ts:31`](../packages/spill/spill-local/src/index.ts) diff --git a/packages/spill/spill-local/README.i18n.yaml b/packages/spill/spill-local/README.i18n.yaml index 37659c3aac..dd414de9b9 100644 --- a/packages/spill/spill-local/README.i18n.yaml +++ b/packages/spill/spill-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/spill/spill-local/README.md -README.md: 2270a65d9270e1549a9e48d6a36b821e48c29070 -README.zh.md: b3e4999d8f2d982ef01637199299f79d06e12c4b +README.md: 75bde20c423e1d2aa4bba49201b5bb0369d34fd0 +README.zh.md: 0539969cc4bd4da8dad4f0ac00436476555fd08c diff --git a/packages/spill/spill-local/README.md b/packages/spill/spill-local/README.md index c97ddf89cd..75bde20c42 100644 --- a/packages/spill/spill-local/README.md +++ b/packages/spill/spill-local/README.md @@ -23,7 +23,7 @@ Files land at `/session-/​-`: The backend never deletes a spill on the write path — a persisted, resumed, or forked session may still reference an older locator, so immediate deletion would break retrieval. Instead, one best-effort sweep runs **once after activation**: it does not delay service availability, is owned by the plugin fiber, and is awaited on disposal (no sweep I/O outlives the fiber). There is no recurring timer and no separate process, so a long-lived deployment is not swept again until its next restart. -The sweep scans the configured `root` **and** any earlier default `dsh-spill-*` temp roots that prior default-root runs left under the OS temp dir. Within each, it deletes regular files whose `mtime` is strictly older than `now − cleanupPeriodDays` and prunes any directory left empty. It never follows or deletes a symlink, skips unrelated entries, and contains every filesystem failure (logged, never thrown) so it cannot fail activation or a concurrent spill write. Retention is deliberate: an old model-visible locator goes stale only once it ages past the cutoff. +The sweep scans the configured `root` **and** any earlier default `dsh-spill-*` temp roots that prior default-root runs left under the OS temp dir. Within each, it deletes regular files whose `mtime` is strictly older than `now − cleanupPeriodDays`; it prunes empty session directories and roots only for discovered prior-default roots, while the active root keeps its session directories to avoid racing a write. A write recreates its session directory if another process prunes a discovered root that is still active. The sweep never follows or deletes a symlink, skips unrelated entries, and contains every filesystem or warning-sink failure so it cannot fail activation or a concurrent spill write. Retention is deliberate: an old model-visible locator goes stale only once it ages past the cutoff. `saveText` rejects on a real storage failure (permissions, ENOSPC); the spill policy treats a rejection as best-effort and keeps the inline result. See the seam README for the vocabulary and the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design, and the [startup-cleanup Agent Note](../../../.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md) for the sweep. diff --git a/packages/spill/spill-local/README.zh.md b/packages/spill/spill-local/README.zh.md index b3e4999d8f..0539969cc4 100644 --- a/packages/spill/spill-local/README.zh.md +++ b/packages/spill/spill-local/README.zh.md @@ -17,8 +17,15 @@ | 键 | 默认值 | 含义 | |---|---|---| | `root` | 私有 0700 临时目录 | spill 文件的根目录。设置后可将这些文件保存在已知位置。 | +| `cleanupPeriodDays` | `30` | spill 文件在一次性启动清理扫描中符合删除条件前需经过的天数。`0` 禁用清理。 | -`saveText` 在发生真实存储故障(权限、ENOSPC)时返回拒绝;spill 策略会按尽力而为原则处理该拒绝,并保留内联结果。词汇见 seam README,设计见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md)。 +## 启动清理 + +后端不会在写入路径上删除 spill,因为已持久化、已恢复或 fork 后的会话仍可能引用较旧的定位信息,立即删除会使其无法取回。后端会改为在激活后**仅运行一次**尽力而为的扫描:扫描不延迟服务可用性,由插件 fiber 拥有,并在 dispose 期间被等待(不会有扫描 I/O 存活至 fiber 之后)。它既不使用周期性定时器,也不运行独立进程,因此长期运行的部署要到下次重启才会再次扫描。 + +扫描会检查配置的 `root` **以及**先前使用默认根目录的运行在操作系统临时目录下留下的所有 `dsh-spill-*` 临时根目录。在每个根目录中,扫描会删除 `mtime` 严格早于 `now − cleanupPeriodDays` 的常规文件;它只修剪发现的先前默认根目录中的空会话目录和空根目录,而活动根目录会保留其会话目录,以避免与写入操作竞争。如果另一个进程修剪了一个仍在使用的发现根目录,写入操作会重新创建其会话目录。扫描绝不会跟随或删除符号链接,会跳过无关条目,并兜底每一次文件系统或警告接收方失败,因此无法使激活或并发 spill 写入失败。保留是刻意的:旧的模型可见定位信息只有超过截止时间后才会失效。 + +`saveText` 在发生真实存储故障(权限、ENOSPC)时返回拒绝;spill 策略会按尽力而为原则处理该拒绝,并保留内联结果。词汇见 seam README,设计见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md),扫描机制见[启动清理 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md)。 ## 模型体验 @@ -30,5 +37,5 @@ ## 已知限制与暂缓事项 -- **本地 spill 文件会持续存在,直到外部清理为止**:该后端不提供会话生命周期删除或按时间保留的策略,因为已持久化、已恢复和 fork 后的会话可能仍在引用某个路径。 +- **长期运行的部署需等到重启才会被扫描**:一次性扫描仅在激活后运行一次,因此运行期间达到 `cleanupPeriodDays` 的文件要到下次启动才会被回收;没有周期性定时器。 - **定位信息需要与其位于同一文件系统的消费方**:远程或虚拟部署需要另一个 `SpillStore` 后端,其定位信息和取回指引在该环境中有明确含义。 diff --git a/packages/spill/spill-local/src/cleanup.ts b/packages/spill/spill-local/src/cleanup.ts new file mode 100644 index 0000000000..c138f68a84 --- /dev/null +++ b/packages/spill/spill-local/src/cleanup.ts @@ -0,0 +1,276 @@ +/** Startup cleanup mechanics for local spill roots. */ +import { lstat, readdir, rmdir, unlink } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { DEFAULT_ROOT_PREFIX, isErrno } from './store.ts' + +/** + * A backend-generated default root name: `dsh-spill-` plus the 6-character + * suffix `mkdtemp` appends. 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 = new RegExp(`^${DEFAULT_ROOT_PREFIX}[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}$/ + +/** A one-argument warning sink — the sweep's only side effect on failure (never throws). */ +export type WarnFn = (message: string) => void + +/** Report a best-effort sweep failure without allowing the warning sink to reject cleanup. */ +function warnSafely(warn: WarnFn, message: string): void { + try { + warn(message) + } catch { + // Warning sinks are observational callbacks; cleanup must remain best-effort + // even when a logger implementation throws. + } +} + +/** One root to sweep, plus whether its empty session directories and root may be pruned. */ +export interface SweepRoot { + /** Absolute spill root to sweep. */ + path: string + /** + * When `true`, prune empty `session-*` children and then remove the root once + * empty. 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. Writes retry + * if another process still using a discovered root races its pruning. + */ + pruneWhenEmpty: boolean +} + +/** Options for {@link sweepSpillRoots} — the roots to scan, the age cutoff, and a failure sink. */ +export interface SweepOptions { + /** 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 + * file written exactly at the boundary is kept (only strictly-older expires). + */ + cutoffMs: number + /** Where a contained filesystem failure is reported; the sweep itself never throws. */ + warn: WarnFn +} + +/** + * Delete a single path, treating a concurrent-race disappearance as success. + * A parallel process (or another sweep) may `unlink` the same file between our + * scan and our own `unlink` — ENOENT then means the goal (file gone) already + * holds, so it is not a failure. Any other error is reported and swallowed. + * + * @param path The absolute file path to remove. + * @param warn Sink for a non-ENOENT failure message. + * @returns Resolves once the removal was attempted (never rejects). + */ +async function unlinkIdempotent(path: string, warn: WarnFn): Promise { + try { + await unlink(path) + } catch (error: unknown) { + /* v8 ignore start -- reached only when a file selected for deletion (a + regular file that passed lstat) then fails to unlink: either it raced away + (ENOENT) or a permission/IO fault struck between the stat and the unlink. + Neither is deterministically reproducible in-process. */ + if (isErrno(error, 'ENOENT')) return + warnSafely(warn, `spill-local: failed to delete ${path}: ${String(error)}`) + /* v8 ignore stop */ + } +} + +/** + * Sweep one spill session directory: delete expired regular files, skip + * everything else, and report the directory empty afterward so the caller can + * 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 (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). + */ +async function sweepSessionDir(dir: string, cutoffMs: number, warn: WarnFn): Promise { + let names: string[] + try { + names = await readdir(dir) + } catch (error: unknown) { + /* v8 ignore start -- the caller lstat'd this entry and confirmed a real + directory just before the call, so readdir fails only when the dir races + away (ENOENT) or a permission/IO fault strikes in that window; not + deterministically reproducible. False keeps it out of the prune step. */ + warnSafely(warn, `spill-local: failed to read ${dir}: ${String(error)}`) + return false + /* v8 ignore stop */ + } + let remaining = names.length + for (const name of names) { + const path = join(dir, name) + let stats + try { + stats = await lstat(path) + } catch (error: unknown) { + /* v8 ignore start -- an entry that readdir just returned then fails to + lstat only by racing away (ENOENT) or a permission/IO fault; keep it out + of the deterministic test surface. */ + if (isErrno(error, 'ENOENT')) { remaining--; continue } + warnSafely(warn, `spill-local: failed to stat ${path}: ${String(error)}`) + continue + /* v8 ignore stop */ + } + // Only regular files expire. Symlinks and other special entries are skipped + // (never followed) so the sweep cannot be redirected or delete a link. + if (!stats.isFile()) continue + if (stats.mtimeMs >= cutoffMs) continue + await unlinkIdempotent(path, warn) + remaining-- + } + return remaining === 0 +} + +/** + * Best-effort one-shot cleanup: across each root, delete expired regular files + * under its `session-*` directories, pruning empty directories only in + * discovered prior-default roots. The active root keeps its session directories + * to avoid racing a local write; writes recreate a directory pruned by another + * process. Every filesystem and warning-sink failure is contained, so a caller + * can await this during activation/disposal without it ever rejecting. + * + * @param options The roots to sweep, the age cutoff, and the failure sink. + * @returns Resolves when the sweep finishes (never rejects). + */ +export async function sweepSpillRoots(options: SweepOptions): Promise { + const { roots, cutoffMs, warn } = options + for (const root of roots) { + let entries: string[] + try { + 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')) warnSafely(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-<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')) warnSafely(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) { rootEmptiable = false; continue } + if (!root.pruneWhenEmpty) { + // The active root remains writable while cleanup runs. Leaving its empty + // session directories in place closes the mkdir/rmdir race with saveText. + rootEmptiable = false + continue + } + try { + await rmdir(dir) + } catch (error: unknown) { + /* v8 ignore start -- prune runs only on a dir observed empty; a failure + 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')) { + warnSafely(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) { + /* v8 ignore start -- prune runs only on a root whose every child was + reclaimed; a failure here means a concurrent writer added a fresh + spill after our scan (ENOTEMPTY) or removed the root already (ENOENT) + or a permission/IO fault struck — all races outside deterministic + in-process testing. */ + if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) { + warnSafely(warn, `spill-local: failed to prune root ${root.path}: ${String(error)}`) + } + /* v8 ignore stop */ + } + } + } +} + +/** + * Discover prior default spill roots: the `dsh-spill-<6 chars>` directories + * directly under `base` (the OS tmpdir) that earlier default-root runs created. + * 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). + * @returns Absolute paths of the discovered default roots (possibly empty). + */ +export async function discoverDefaultRoots(warn: WarnFn, base: string = tmpdir()): Promise { + let entries: string[] + try { + entries = await readdir(base) + } catch (error: unknown) { + warnSafely(warn, `spill-local: failed to scan ${base} for default roots: ${String(error)}`) + return [] + } + const roots: string[] = [] + for (const name of entries) { + if (!DEFAULT_ROOT_RE.test(name)) continue + const path = join(base, name) + let stats + try { + // lstat, not stat: a symlink named `dsh-spill-*` must not be treated as a + // root we then sweep (it could point anywhere). + stats = await lstat(path) + } 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')) warnSafely(warn, `spill-local: failed to stat default root ${path}: ${String(error)}`) + continue + /* v8 ignore stop */ + } + if (stats.isDirectory()) roots.push(path) + } + return roots +} diff --git a/packages/spill/spill-local/src/index.ts b/packages/spill/spill-local/src/index.ts index 7c4069c539..f767a1a12d 100644 --- a/packages/spill/spill-local/src/index.ts +++ b/packages/spill/spill-local/src/index.ts @@ -15,11 +15,14 @@ import { tmpdir } from 'node:os' 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 { SweepRoot, WarnFn } from './store.ts' +import { discoverDefaultRoots, sweepSpillRoots } from './cleanup.ts' +import type { SweepRoot, WarnFn } from './cleanup.ts' +import { privateRoot, saveTextFile } from './store.ts' -export { discoverDefaultRoots, encodeSegment, isErrno, privateRoot, saveTextFile, sessionDir, sweepSpillRoots, DEFAULT_ROOT_PREFIX } from './store.ts' -export type { SavedText, SaveTextOptions, SweepOptions, SweepRoot, WarnFn } from './store.ts' +export { discoverDefaultRoots, sweepSpillRoots } from './cleanup.ts' +export type { SweepOptions, SweepRoot, WarnFn } from './cleanup.ts' +export { DEFAULT_ROOT_PREFIX, encodeSegment, isErrno, privateRoot, saveTextFile, sessionDir } from './store.ts' +export type { SavedText, SaveTextOptions } from './store.ts' /** Milliseconds in one day — converts the `cleanupPeriodDays` config to the sweep cutoff. */ const MS_PER_DAY = 24 * 60 * 60 * 1000 @@ -117,9 +120,10 @@ export class LocalSpillStore extends SpillStore { /** * 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 + * emptied, plus the active/configured root, whose root and session directories + * are NEVER pruned (the live process is still writing into them). 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. diff --git a/packages/spill/spill-local/src/store.ts b/packages/spill/spill-local/src/store.ts index 0303a8f639..ba518cf035 100644 --- a/packages/spill/spill-local/src/store.ts +++ b/packages/spill/spill-local/src/store.ts @@ -8,44 +8,30 @@ import { createHash, randomBytes } from 'node:crypto' import { mkdtempSync } from 'node:fs' -import { lstat, mkdir, open, readdir, rmdir, unlink } from 'node:fs/promises' +import { mkdir, open } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -/** - * Filename prefix for the lazily-created private default spill roots - * (`mkdtemp(tmpdir()/dsh-spill-)`). Startup cleanup rediscovers these - * per-process roots (from prior runs that used no configured `root`) by this - * prefix — see {@link discoverDefaultRoots}. - */ +/** Prefix shared by default-root creation and startup discovery. */ 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. + * Test a caught value for a Node system error code. + * + * @param error The caught value. + * @param code The expected system error code. + * @returns Whether the code matches. */ -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}$/ +export function isErrno(error: unknown, code: string): boolean { + return error instanceof Error && (error as NodeJS.ErrnoException).code === code +} let defaultRoot: string | undefined /** - * The default spill root: a private (0700) per-process directory under the OS - * tmpdir, created lazily. Predictable world-readable paths would let other - * local users read spilled tool output or pre-create symlinks; `mkdtemp` gives - * an unpredictable suffix and 0700 semantics. + * Return the lazily-created private per-process spill root. * - * @returns The lazily-created private spill root. + * @returns The private root path. */ export function privateRoot(): string { defaultRoot ??= mkdtempSync(join(tmpdir(), DEFAULT_ROOT_PREFIX)) @@ -63,8 +49,8 @@ export function privateRoot(): string { * inputs never collide. The whole-segment tokens `.`/`..` are escaped so they * can never traverse. An empty string encodes to `~` (never an empty segment). * - * @param raw The untrusted string to encode as one safe path segment. - * @returns An injective, filesystem-safe single path segment. + * @param raw Untrusted text. + * @returns One injective filesystem-safe path segment. */ export function encodeSegment(raw: string): string { if (raw.length === 0) return '~' @@ -74,317 +60,72 @@ export function encodeSegment(raw: string): string { for (let i = 0; i < raw.length; i++) { const code = raw.charCodeAt(i) const ch = String.fromCharCode(code) - if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) { - out += ch - } else { - out += '~' + code.toString(16).toUpperCase().padStart(4, '0') - } + out += ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch) + ? ch + : '~' + code.toString(16).toUpperCase().padStart(4, '0') } return out } /* jscpd:ignore-end */ /** - * The session-scoped directory: `/session-`, a short stable hash. + * Derive the stable session-scoped directory under a spill root. * - * @param root The spill root directory. - * @param sessionId The owning session id to hash into a stable directory name. - * @returns The absolute session-scoped spill directory path. + * @param root The spill root. + * @param sessionId The owning session id. + * @returns The stable session-scoped directory. */ export function sessionDir(root: string, sessionId: string): string { const hash = createHash('sha256').update(sessionId).digest('hex').slice(0, 12) return join(root, `session-${hash}`) } -/** Options for {@link saveTextFile} — the resolved root and the request fields the store needs. */ +/** Inputs needed to save a local spill file. */ export interface SaveTextOptions { - /** The spill root directory (configured or the lazy private default). */ + /** Spill root. */ root: string - /** The owning session id (scopes the directory). */ + /** Owning session id. */ sessionId: string - /** Caller-suggested base name; sanitized to one safe segment before use. */ + /** Caller-suggested filename. */ suggestedName: string - /** The full text to persist. */ + /** Full text to persist. */ content: string } /** A written spill file. */ export interface SavedText { + /** Absolute saved path. */ path: string + /** UTF-8 content length. */ bytes: number } /** - * Write `content` to a fresh file under the session-scoped directory and return - * its path + byte length. The filename is a random hex prefix plus the - * sanitized `suggestedName`, so it is unpredictable (defeats symlink planting in - * a shared root) AND stays readable. The open is exclusive + owner-only - * (`'wx', 0o600`): it fails on any existing path — symlink or not — so a - * pre-planted target cannot redirect the write. - * - * @param options The resolved root and request fields required to save the file. - * @returns The written file path and UTF-8 byte length. + * Write text to a fresh 0600 file below its private session directory. + * @param options The save request. + * @returns The saved path and UTF-8 byte length. */ export async function saveTextFile(options: SaveTextOptions): Promise { const dir = sessionDir(options.root, options.sessionId) - await mkdir(dir, { recursive: true, mode: 0o700 }) - const safeName = encodeSegment(options.suggestedName) - const path = join(dir, `${randomBytes(6).toString('hex')}-${safeName}`) - const bytes = Buffer.byteLength(options.content, 'utf8') - const handle = await open(path, 'wx', 0o600) + const path = join(dir, `${randomBytes(6).toString('hex')}-${encodeSegment(options.suggestedName)}`) + let handle + for (;;) { + await mkdir(dir, { recursive: true, mode: 0o700 }) + try { + handle = await open(path, 'wx', 0o600) + break + } catch (error: unknown) { + /* v8 ignore start -- requires another process to remove the directory + between mkdir and open, or an external permission/IO race. */ + if (isErrno(error, 'ENOENT')) continue + throw error + /* v8 ignore stop */ + } + } try { await handle.writeFile(options.content) } finally { await handle.close() } - return { path, bytes } -} - -/** 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 { - /** 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 - * file written exactly at the boundary is kept (only strictly-older expires). - */ - cutoffMs: number - /** Where a contained filesystem failure is reported; the sweep itself never throws. */ - warn: WarnFn -} - -/** - * Delete a single path, treating a concurrent-race disappearance as success. - * A parallel process (or another sweep) may `unlink` the same file between our - * scan and our own `unlink` — ENOENT then means the goal (file gone) already - * holds, so it is not a failure. Any other error is reported and swallowed. - * - * @param path The absolute file path to remove. - * @param warn Sink for a non-ENOENT failure message. - * @returns Resolves once the removal was attempted (never rejects). - */ -async function unlinkIdempotent(path: string, warn: WarnFn): Promise { - try { - await unlink(path) - } catch (error: unknown) { - /* v8 ignore start -- reached only when a file selected for deletion (a - regular file that passed lstat) then fails to unlink: either it raced away - (ENOENT) or a permission/IO fault struck between the stat and the unlink. - Neither is deterministically reproducible in-process. */ - if (isErrno(error, 'ENOENT')) return - warn(`spill-local: failed to delete ${path}: ${String(error)}`) - /* v8 ignore stop */ - } -} - -/** - * True when `error` is a Node system error carrying the given `code`. - * - * @param error The caught value to test. - * @param code The `NodeJS.ErrnoException` code to match (e.g. `'ENOENT'`). - * @returns `true` when `error` is an `Error` whose `code` equals `code`. - */ -export function isErrno(error: unknown, code: string): boolean { - return error instanceof Error && (error as NodeJS.ErrnoException).code === code -} - -/** - * Sweep one spill session directory: delete expired regular files, skip - * everything else, and report the directory empty afterward so the caller can - * 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 (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). - */ -async function sweepSessionDir(dir: string, cutoffMs: number, warn: WarnFn): Promise { - let names: string[] - try { - names = await readdir(dir) - } catch (error: unknown) { - /* v8 ignore start -- the caller lstat'd this entry and confirmed a real - directory just before the call, so readdir fails only when the dir races - away (ENOENT) or a permission/IO fault strikes in that window; not - deterministically reproducible. False keeps it out of the prune step. */ - warn(`spill-local: failed to read ${dir}: ${String(error)}`) - return false - /* v8 ignore stop */ - } - let remaining = names.length - for (const name of names) { - const path = join(dir, name) - let stats - try { - stats = await lstat(path) - } catch (error: unknown) { - /* v8 ignore start -- an entry that readdir just returned then fails to - lstat only by racing away (ENOENT) or a permission/IO fault; keep it out - of the deterministic test surface. */ - if (isErrno(error, 'ENOENT')) { remaining--; continue } - warn(`spill-local: failed to stat ${path}: ${String(error)}`) - continue - /* v8 ignore stop */ - } - // Only regular files expire. Symlinks and other special entries are skipped - // (never followed) so the sweep cannot be redirected or delete a link. - if (!stats.isFile()) continue - if (stats.mtimeMs >= cutoffMs) continue - await unlinkIdempotent(path, warn) - remaining-- - } - return remaining === 0 -} - -/** - * Best-effort one-shot cleanup: across each root, delete expired regular files - * under its `session-*` directories and prune any directory left empty. The - * sweep is idempotent and safe to run concurrently with live spill writes and - * with another process's sweep — per-file expiry preserves a fresh write even - * if it lands mid-sweep, and every filesystem failure is caught and reported - * rather than thrown, so a caller can await this during activation/disposal - * without it ever rejecting. - * - * @param options The roots to sweep, the age cutoff, and the failure sink. - * @returns Resolves when the sweep finishes (never rejects). - */ -export async function sweepSpillRoots(options: SweepOptions): Promise { - const { roots, cutoffMs, warn } = options - for (const root of roots) { - let entries: string[] - try { - 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.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-<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) { rootEmptiable = false; continue } - try { - await rmdir(dir) - } catch (error: unknown) { - /* v8 ignore start -- prune runs only on a dir observed empty; a failure - 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) { - /* v8 ignore start -- prune runs only on a root whose every child was - reclaimed; a failure here means a concurrent writer added a fresh - spill after our scan (ENOTEMPTY) or removed the root already (ENOENT) - or a permission/IO fault struck — all races outside deterministic - in-process testing. */ - if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) { - warn(`spill-local: failed to prune root ${root.path}: ${String(error)}`) - } - /* v8 ignore stop */ - } - } - } -} - -/** - * 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). - * @returns Absolute paths of the discovered default roots (possibly empty). - */ -export async function discoverDefaultRoots(warn: WarnFn, base: string = tmpdir()): Promise { - let entries: string[] - try { - entries = await readdir(base) - } catch (error: unknown) { - warn(`spill-local: failed to scan ${base} for default roots: ${String(error)}`) - return [] - } - const roots: string[] = [] - for (const name of entries) { - if (!DEFAULT_ROOT_RE.test(name)) continue - const path = join(base, name) - let stats - try { - // lstat, not stat: a symlink named `dsh-spill-*` must not be treated as a - // root we then sweep (it could point anywhere). - stats = await lstat(path) - } 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 default root ${path}: ${String(error)}`) - continue - /* v8 ignore stop */ - } - if (stats.isDirectory()) roots.push(path) - } - return roots + return { path, bytes: Buffer.byteLength(options.content, 'utf8') } } diff --git a/packages/spill/spill-local/tests/spill-local.spec.ts b/packages/spill/spill-local/tests/spill-local.spec.ts index 41d35824c6..8bdb5a7471 100644 --- a/packages/spill/spill-local/tests/spill-local.spec.ts +++ b/packages/spill/spill-local/tests/spill-local.spec.ts @@ -3,10 +3,10 @@ * returns a locator + byte length + retrieval hint, filename sanitization * neutralizes traversal, the configured `root` is honored (and the private * default when omitted), and a storage failure rejects. The startup cleanup - * sweep expires old files, prunes empty dirs, skips symlinks/unknown entries, + * sweep expires old files, prunes stale roots, skips symlinks/unknown entries, * discovers prior default roots, contains filesystem failures, and is awaited on - * disposal without blocking activation. The Cordis-free `store.ts` helpers are - * exercised directly for the naming/encoding and sweep edge cases. + * disposal without blocking activation. The Cordis-free store and cleanup + * helpers are exercised directly for their edge cases. */ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' @@ -280,7 +280,7 @@ describe('startup cleanup sweep', () => { expect(existsSync(old)).toBe(true) }) - it('prunes a session directory left empty, keeps one with a surviving file', async () => { + it('keeps active session directories after deleting expired files', async () => { const emptied = sessionDir(root, 'emptied') const kept = sessionDir(root, 'kept') mkdirSync(emptied, { recursive: true }) @@ -288,7 +288,7 @@ describe('startup cleanup sweep', () => { writeAged(join(emptied, 'a.txt'), 'x', 40) writeAged(join(kept, 'fresh.txt'), 'y', 1) await runSweep([active(root)]) - expect(existsSync(emptied)).toBe(false) + expect(existsSync(emptied)).toBe(true) expect(existsSync(kept)).toBe(true) }) @@ -351,7 +351,7 @@ describe('startup cleanup sweep', () => { 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 + expect(existsSync(activeDir)).toBe(true) // active session dirs remain writable } finally { rmSync(prior, { recursive: true, force: true }) } @@ -458,6 +458,13 @@ describe('startup cleanup sweep', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read root')) }) + it('contains an exception from the warning sink', async () => { + const filePath = join(root, 'not-a-dir'); writeFileSync(filePath, 'x') + const warn = vi.fn(() => { throw new Error('logger failed') }) + await expect(sweepSpillRoots({ roots: [active(filePath)], cutoffMs: Date.now(), warn })).resolves.toBeUndefined() + expect(warn).toHaveBeenCalledOnce() + }) + it('a nonexistent root is silent (the common no-spill-yet case)', async () => { const warn = vi.fn() await sweepSpillRoots({ roots: [active(join(root, 'never-created'))], cutoffMs: Date.now(), warn }) @@ -500,4 +507,3 @@ describe('isErrno', () => { expect(isErrno(new Error('no code'), 'ENOENT')).toBe(false) }) }) -