Files
Codeman/test/paste-image-gc.test.ts
T
JD ba36cc36d4 fix(uploads): list upload dirs bounded and async, and keep uploads inside the data dir collected
The hourly sweep called lstatSync and realpathSync on every live session's working directory, so a linked case on a mount that stopped answering blocked the event loop 30 s after boot and then every hour; the old sweep was fully async. uploadDirs() now probes the workspace with probePathKind() first and skips an unknown path without touching it, then uses fs.promises for the lstat and realpath. The sweep keeps the probe's stall cap; cleanupSession(), acting on one path at the user's request, passes pastCap and removes the directories with fs.rm.

Refusing an upload dir that sits strictly inside the data dir protected nothing (it only ever holds uploads, and a link is already excluded by lstat) while the route kept writing there, so uploads under a workspace like the ~/.codeman/app that install.sh clones were never swept and never removed. Only the two cases that matter stay refused: the upload dir being the data dir, or containing it.

Also: the ignore file's take-back after a failed write no longer replaces the error that caused it with its own, and the shared-dir test's header no longer names the fixed port it stopped using.
2026-10-09 20:05:21 -04:00

158 lines
7.7 KiB
TypeScript

/**
* @fileoverview The hourly upload sweep reads BOTH upload dirs of a live session:
* `.codeman-uploads` and the `.claude-images` a case in use before the move still
* carries. Only `paste-*` regular files past the age cap go. `uploadDirs()` never
* lists a planted symlink, a directory that is or contains the data dir, anything
* under a workspace whose bounded probe did not answer, or anything for a remote
* session, since the sweep and the delete cleanup both act on what it returns.
*/
import { existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs';
import fsp from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { LEGACY_UPLOADS_DIR, UPLOADS_DIR, sweepPasteImagesOnce, uploadDirs } from '../src/web/paste-image-gc.js';
import { probePathKind } from '../src/utils/bounded-path-probe.js';
vi.mock('../src/utils/bounded-path-probe.js', async (importOriginal) => {
const real = await importOriginal<typeof import('../src/utils/bounded-path-probe.js')>();
return { ...real, probePathKind: vi.fn(real.probePathKind) };
});
const DAY_MS = 24 * 60 * 60 * 1000;
const sessionsOf = (workingDir: string, remote?: unknown) => ({
sessions: new Map([['s1', { workingDir, remote } as never]]) as never,
});
describe('sweepPasteImagesOnce', () => {
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
it('ages paste-* files out of both the current and the legacy upload dir', async () => {
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-'));
dirs.push(workingDir);
const now = Date.now();
const current = join(workingDir, UPLOADS_DIR);
const legacy = join(workingDir, LEGACY_UPLOADS_DIR);
mkdirSync(current);
mkdirSync(legacy);
expect(await uploadDirs({ workingDir })).toEqual([current, legacy]);
const old = (now - 8 * DAY_MS) / 1000;
const fresh = (now - 1 * DAY_MS) / 1000;
const plant = (dir: string, name: string, mtime: number) => {
writeFileSync(join(dir, name), 'x');
utimesSync(join(dir, name), mtime, mtime);
};
plant(current, 'paste-1-aa.pdf', old);
plant(current, 'paste-2-bb.png', fresh);
plant(current, 'keep.png', old); // not an upload of ours
plant(legacy, 'paste-0-cc.png', old);
symlinkSync(join(legacy, 'paste-0-cc.png'), join(current, 'paste-3-dd.png'));
utimesSync(join(current, 'paste-3-dd.png'), old, old);
const result = await sweepPasteImagesOnce(sessionsOf(workingDir), now);
expect(result.deleted).toBe(2);
expect(existsSync(join(current, 'paste-1-aa.pdf'))).toBe(false);
expect(existsSync(join(legacy, 'paste-0-cc.png'))).toBe(false);
expect(existsSync(join(current, 'paste-2-bb.png'))).toBe(true);
expect(existsSync(join(current, 'keep.png'))).toBe(true);
// A planted symlink is skipped (lstat, never followed), so the sweep leaves the link itself.
expect(lstatSync(join(current, 'paste-3-dd.png')).isSymbolicLink()).toBe(true);
});
it('never reads through a planted symlink at the upload dir', async () => {
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-'));
const other = mkdtempSync(join(tmpdir(), 'codeman-gc-other-'));
dirs.push(workingDir, other);
const now = Date.now();
const old = (now - 8 * DAY_MS) / 1000;
mkdirSync(join(other, UPLOADS_DIR));
writeFileSync(join(other, UPLOADS_DIR, 'paste-9-zz.png'), 'x');
utimesSync(join(other, UPLOADS_DIR, 'paste-9-zz.png'), old, old);
// readdir follows a link to a directory, so a listed link would let the sweep
// age out the other case's uploads through it.
symlinkSync(join(other, UPLOADS_DIR), join(workingDir, UPLOADS_DIR));
expect(await uploadDirs({ workingDir })).toEqual([]);
expect(await sweepPasteImagesOnce(sessionsOf(workingDir), now)).toEqual({ scanned: 0, deleted: 0 });
expect(existsSync(join(other, UPLOADS_DIR, 'paste-9-zz.png'))).toBe(true);
});
});
describe('uploadDirs', () => {
const dirs: string[] = [];
const savedDataDir = process.env.CODEMAN_DATA_DIR;
afterEach(() => {
if (savedDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
else process.env.CODEMAN_DATA_DIR = savedDataDir;
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
it('lists the upload dir of a home workspace, which sits beside the data dir, not inside it', async () => {
// The home-as-workspace case the flat name exists for: `<home>/.codeman-uploads`
// is a sibling of `<home>/.codeman`, so a prefix test without the separator would
// wrongly refuse it.
const home = mkdtempSync(join(tmpdir(), 'codeman-gc-home-'));
dirs.push(home);
process.env.CODEMAN_DATA_DIR = join(home, '.codeman');
const dir = join(home, UPLOADS_DIR);
mkdirSync(dir);
expect(await uploadDirs({ workingDir: home })).toEqual([dir]);
});
it('refuses an upload dir that is the data dir or contains it, and lists one inside it', async () => {
const root = mkdtempSync(join(tmpdir(), 'codeman-gc-data-'));
const parent = mkdtempSync(join(tmpdir(), 'codeman-gc-link-'));
dirs.push(root, parent);
const uploads = join(root, UPLOADS_DIR);
mkdirSync(join(uploads, 'state'), { recursive: true });
// `CODEMAN_INSTANCE=uploads` on a home workspace: the upload dir IS the data dir.
process.env.CODEMAN_DATA_DIR = uploads;
expect(await uploadDirs({ workingDir: root })).toEqual([]);
// A data dir pointed inside the upload dir: the recursive delete would take it.
process.env.CODEMAN_DATA_DIR = join(uploads, 'state');
expect(await uploadDirs({ workingDir: root })).toEqual([]);
// An upload dir strictly inside the data dir only ever holds uploads (install.sh
// clones the app to ~/.codeman/app), so it is listed, directly and through a
// symlinked working directory.
process.env.CODEMAN_DATA_DIR = root;
expect(await uploadDirs({ workingDir: root })).toEqual([uploads]);
symlinkSync(root, join(parent, 'ws'));
expect(await uploadDirs({ workingDir: join(parent, 'ws') })).toEqual([join(parent, 'ws', UPLOADS_DIR)]);
});
it('skips a workspace whose bounded probe does not answer, without touching it', async () => {
// A linked case on a mount that stopped answering reads `unknown` (#516): the
// sweep must not lstat under it, which would hold a threadpool worker.
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-stalled-'));
dirs.push(workingDir);
mkdirSync(join(workingDir, UPLOADS_DIR));
vi.mocked(probePathKind).mockResolvedValueOnce('unknown');
const lstat = vi.spyOn(fsp, 'lstat');
try {
expect(await uploadDirs({ workingDir })).toEqual([]);
expect(lstat).not.toHaveBeenCalled();
} finally {
lstat.mockRestore();
}
expect(probePathKind).toHaveBeenCalledWith(workingDir, {});
});
it('lists nothing for a remote session, whose workingDir is the remote path', async () => {
// The same path on THIS host belongs to whoever has a local case there.
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-remote-'));
dirs.push(workingDir);
mkdirSync(join(workingDir, UPLOADS_DIR));
writeFileSync(join(workingDir, UPLOADS_DIR, 'paste-1-aa.png'), 'x');
const old = (Date.now() - 8 * DAY_MS) / 1000;
utimesSync(join(workingDir, UPLOADS_DIR, 'paste-1-aa.png'), old, old);
const remote = { hostId: 'gpu-box' };
expect(await uploadDirs({ workingDir, remote })).toEqual([]);
expect(await sweepPasteImagesOnce(sessionsOf(workingDir, remote))).toEqual({ scanned: 0, deleted: 0 });
expect(existsSync(join(workingDir, UPLOADS_DIR, 'paste-1-aa.png'))).toBe(true);
});
});