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.
This commit is contained in:
JD
2026-10-09 20:05:21 -04:00
parent f12b5ac88b
commit ba36cc36d4
7 changed files with 105 additions and 43 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1063,7 +1063,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
**Prompt uploads live in `<workspace>/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `<workspace>/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is, contains or sits inside `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 400 before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`. **Prompt uploads live in `<workspace>/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `<workspace>/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 400 before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`.
### Process-tree walks are bounded ### Process-tree walks are bounded
+41 -18
View File
@@ -13,9 +13,10 @@
* upload dir. * upload dir.
*/ */
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { lstatSync, realpathSync } from 'node:fs'; import { realpathSync } from 'node:fs';
import { join, resolve, sep } from 'node:path'; import { join, resolve, sep } from 'node:path';
import { getDataDir } from '../config/instance.js'; import { getDataDir } from '../config/instance.js';
import { probePathKind, type PathProbeOptions } from '../utils/bounded-path-probe.js';
import type { SessionPort } from './ports/index.js'; import type { SessionPort } from './ports/index.js';
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
@@ -43,34 +44,56 @@ export const UPLOAD_DIR_NAMES = [UPLOADS_DIR, LEGACY_UPLOADS_DIR];
* act on what this returns, the hourly sweep and the recursive delete in * act on what this returns, the hourly sweep and the recursive delete in
* cleanupSession(), so it lists only REAL directories (a link planted by a * cleanupSession(), so it lists only REAL directories (a link planted by a
* workspace script, `.codeman-uploads -> /other-case/.codeman-uploads`, is * workspace script, `.codeman-uploads -> /other-case/.codeman-uploads`, is
* not one; readdir follows a link to a directory), none that is, contains or * not one; readdir follows a link to a directory), none that is or contains
* sits inside this instance's data dir (a home workspace reaches it under a * this instance's data dir (a home workspace reaches it under a contrived
* contrived instance name, `CODEMAN_INSTANCE=uploads`, and `CODEMAN_DATA_DIR` * instance name, `CODEMAN_INSTANCE=uploads`, and `CODEMAN_DATA_DIR` can point
* can point inside one), and nothing for a remote (SSH) session, whose * inside one; a directory strictly below the data dir only ever holds uploads
* and is listed, or the uploads of a workspace like `~/.codeman/app` would
* never be collected), and nothing for a remote (SSH) session, whose
* workingDir is the remote path and would name a same-named LOCAL directory * workingDir is the remote path and would name a same-named LOCAL directory
* here. The check is made when listing: a same-user process that swaps a * here. The working directory is a user-chosen path, so it is probed BOUNDED
* listed directory for a link afterwards is accepted, since it already writes * first (#516): one on a mount that stopped answering reads `unknown` and is
* anywhere this process can. * skipped, never touched. The sweep keeps the probe's stall cap; the delete,
* acting on one path at the user's request, passes `pastCap`. The check is
* made when listing: a same-user process that swaps a listed directory for a
* link afterwards is accepted, since it already writes anywhere this process
* can.
*/ */
export function uploadDirs(session: { workingDir: string; remote?: unknown }): string[] { export async function uploadDirs(
session: { workingDir: string; remote?: unknown },
probe: PathProbeOptions = {}
): Promise<string[]> {
if (session.remote) return []; if (session.remote) return [];
const dataDir = canonicalDir(getDataDir()); if ((await probePathKind(session.workingDir, probe)) !== 'directory') return [];
return UPLOAD_DIR_NAMES.map((name) => join(session.workingDir, name)).filter((dir) => { const dataDir = await realDir(getDataDir());
if (!isRealDir(dir)) return false; const dirs: string[] = [];
const real = canonicalDir(dir); for (const dir of UPLOAD_DIR_NAMES.map((name) => join(session.workingDir, name))) {
return real !== dataDir && !real.startsWith(dataDir + sep) && !dataDir.startsWith(real + sep); if (!(await isRealDir(dir))) continue;
}); const real = await realDir(dir);
if (real === dataDir || dataDir.startsWith(real + sep)) continue;
dirs.push(dir);
}
return dirs;
} }
/** lstat, so a symlink is not a directory, whatever it points at. */ /** lstat, so a symlink is not a directory, whatever it points at. */
function isRealDir(p: string): boolean { async function isRealDir(p: string): Promise<boolean> {
try { try {
return lstatSync(p).isDirectory(); return (await fs.lstat(p)).isDirectory();
} catch { } catch {
return false; return false;
} }
} }
/** `canonicalDir()` for the listing, which must not block the event loop on a user path. */
async function realDir(dir: string): Promise<string> {
try {
return await fs.realpath(dir);
} catch {
return resolve(dir);
}
}
export async function sweepPasteImagesOnce( export async function sweepPasteImagesOnce(
ctx: Pick<SessionPort, 'sessions'>, ctx: Pick<SessionPort, 'sessions'>,
now: number = Date.now() now: number = Date.now()
@@ -79,7 +102,7 @@ export async function sweepPasteImagesOnce(
let scanned = 0; let scanned = 0;
let deleted = 0; let deleted = 0;
for (const session of ctx.sessions.values()) { for (const session of ctx.sessions.values()) {
for (const dir of uploadDirs(session)) { for (const dir of await uploadDirs(session)) {
let entries: string[]; let entries: string[];
try { try {
entries = await fs.readdir(dir); entries = await fs.readdir(dir);
+3 -2
View File
@@ -399,8 +399,9 @@ async function ensureUploadDir(workingDir: string): Promise<string | null> {
} catch (err: unknown) { } catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return uploadDir; if ((err as NodeJS.ErrnoException).code === 'EEXIST') return uploadDir;
// The create can succeed before the write fails (ENOSPC): an empty ignore // The create can succeed before the write fails (ENOSPC): an empty ignore
// file would read as the user's on the next upload, so take it back. // file would read as the user's on the next upload, so take it back; its own
await fs.rm(ignoreFile, { force: true }); // failure must not replace the cause.
await fs.rm(ignoreFile, { force: true }).catch(() => {});
throw err; throw err;
} }
return uploadDir; return uploadDir;
+6 -4
View File
@@ -37,7 +37,7 @@ import { pasteImageDirInUseByOtherSession, startPasteImageGc, uploadDirs } from
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js'; import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs'; import { existsSync, mkdirSync, readFileSync, chmodSync, statSync } from 'node:fs';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { hostname as getHostname, uptime as osUptime } from 'node:os'; import { hostname as getHostname, uptime as osUptime } from 'node:os';
@@ -1547,10 +1547,12 @@ export class WebServer extends EventEmitter {
}) })
) { ) {
// Both upload dirs, the pre-move one too; uploadDirs() lists only real // Both upload dirs, the pre-move one too; uploadDirs() lists only real
// directories clear of the data dir, and none for a remote session. // directories that are not the data dir and do not contain it, none for a
for (const uploadDir of uploadDirs(session)) { // remote session, and nothing on a workspace whose bounded probe did not
// answer (pastCap: this acts on one path at the user's request).
for (const uploadDir of await uploadDirs(session, { pastCap: true })) {
try { try {
rmSync(uploadDir, { recursive: true, force: true }); await fs.rm(uploadDir, { recursive: true, force: true });
} catch { } catch {
// Best-effort cleanup // Best-effort cleanup
} }
+12 -1
View File
@@ -10,7 +10,7 @@
* closes sessions unattended, which turns that from an occasional loss into a * closes sessions unattended, which turns that from an occasional loss into a
* routine one. * routine one.
* *
* Port: 3188 * Port: 0 (ephemeral, read from `server.boundPort`)
*/ */
import { existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
@@ -18,6 +18,12 @@ import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { WebServer } from '../src/web/server.js'; import { WebServer } from '../src/web/server.js';
import { pasteImageDirInUseByOtherSession } from '../src/web/paste-image-gc.js'; import { pasteImageDirInUseByOtherSession } 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) };
});
describe('pasteImageDirInUseByOtherSession', () => { describe('pasteImageDirInUseByOtherSession', () => {
const none = new Set<string>(); const none = new Set<string>();
@@ -148,9 +154,14 @@ describe('deleting a session that shares its working directory', () => {
expect(existsSync(join(uploadDir, 'paste-1.png'))).toBe(true); expect(existsSync(join(uploadDir, 'paste-1.png'))).toBe(true);
expect(existsSync(join(legacyDir, 'paste-0.png'))).toBe(true); expect(existsSync(join(legacyDir, 'paste-0.png'))).toBe(true);
// The delete acts on one path at the user's request, so its probe goes past
// the bulk stall cap (#516); the hourly sweep keeps the cap. Cleared first:
// the create path probes the same workspace past the cap too.
vi.mocked(probePathKind).mockClear();
expect((await remove(second)).status).toBe(200); expect((await remove(second)).status).toBe(200);
expect(existsSync(uploadDir)).toBe(false); expect(existsSync(uploadDir)).toBe(false);
expect(existsSync(legacyDir)).toBe(false); expect(existsSync(legacyDir)).toBe(false);
expect(probePathKind).toHaveBeenCalledWith(workingDir, { pastCap: true });
}); });
it('does not follow a planted symlink at the upload dir into another case when the last session closes', async () => { it('does not follow a planted symlink at the upload dir into another case when the last session closes', async () => {
+41 -16
View File
@@ -2,15 +2,22 @@
* @fileoverview The hourly upload sweep reads BOTH upload dirs of a live session: * @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 * `.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 * carries. Only `paste-*` regular files past the age cap go. `uploadDirs()` never
* lists a planted symlink, a directory that is, contains or sits inside the data * lists a planted symlink, a directory that is or contains the data dir, anything
* dir, or anything for a remote session, since the sweep and the delete cleanup * under a workspace whose bounded probe did not answer, or anything for a remote
* both act on what it returns. * 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 { existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs';
import fsp from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { LEGACY_UPLOADS_DIR, UPLOADS_DIR, sweepPasteImagesOnce, uploadDirs } from '../src/web/paste-image-gc.js'; 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 DAY_MS = 24 * 60 * 60 * 1000;
const sessionsOf = (workingDir: string, remote?: unknown) => ({ const sessionsOf = (workingDir: string, remote?: unknown) => ({
@@ -31,7 +38,7 @@ describe('sweepPasteImagesOnce', () => {
const legacy = join(workingDir, LEGACY_UPLOADS_DIR); const legacy = join(workingDir, LEGACY_UPLOADS_DIR);
mkdirSync(current); mkdirSync(current);
mkdirSync(legacy); mkdirSync(legacy);
expect(uploadDirs({ workingDir })).toEqual([current, legacy]); expect(await uploadDirs({ workingDir })).toEqual([current, legacy]);
const old = (now - 8 * DAY_MS) / 1000; const old = (now - 8 * DAY_MS) / 1000;
const fresh = (now - 1 * DAY_MS) / 1000; const fresh = (now - 1 * DAY_MS) / 1000;
const plant = (dir: string, name: string, mtime: number) => { const plant = (dir: string, name: string, mtime: number) => {
@@ -69,7 +76,7 @@ describe('sweepPasteImagesOnce', () => {
// age out the other case's uploads through it. // age out the other case's uploads through it.
symlinkSync(join(other, UPLOADS_DIR), join(workingDir, UPLOADS_DIR)); symlinkSync(join(other, UPLOADS_DIR), join(workingDir, UPLOADS_DIR));
expect(uploadDirs({ workingDir })).toEqual([]); expect(await uploadDirs({ workingDir })).toEqual([]);
expect(await sweepPasteImagesOnce(sessionsOf(workingDir), now)).toEqual({ scanned: 0, deleted: 0 }); expect(await sweepPasteImagesOnce(sessionsOf(workingDir), now)).toEqual({ scanned: 0, deleted: 0 });
expect(existsSync(join(other, UPLOADS_DIR, 'paste-9-zz.png'))).toBe(true); expect(existsSync(join(other, UPLOADS_DIR, 'paste-9-zz.png'))).toBe(true);
}); });
@@ -84,7 +91,7 @@ describe('uploadDirs', () => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); 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', () => { 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` // 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 // is a sibling of `<home>/.codeman`, so a prefix test without the separator would
// wrongly refuse it. // wrongly refuse it.
@@ -93,10 +100,10 @@ describe('uploadDirs', () => {
process.env.CODEMAN_DATA_DIR = join(home, '.codeman'); process.env.CODEMAN_DATA_DIR = join(home, '.codeman');
const dir = join(home, UPLOADS_DIR); const dir = join(home, UPLOADS_DIR);
mkdirSync(dir); mkdirSync(dir);
expect(uploadDirs({ workingDir: home })).toEqual([dir]); expect(await uploadDirs({ workingDir: home })).toEqual([dir]);
}); });
it('refuses an upload dir that is the data dir, contains it, or sits inside it', () => { 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 root = mkdtempSync(join(tmpdir(), 'codeman-gc-data-'));
const parent = mkdtempSync(join(tmpdir(), 'codeman-gc-link-')); const parent = mkdtempSync(join(tmpdir(), 'codeman-gc-link-'));
dirs.push(root, parent); dirs.push(root, parent);
@@ -104,16 +111,34 @@ describe('uploadDirs', () => {
mkdirSync(join(uploads, 'state'), { recursive: true }); mkdirSync(join(uploads, 'state'), { recursive: true });
// `CODEMAN_INSTANCE=uploads` on a home workspace: the upload dir IS the data dir. // `CODEMAN_INSTANCE=uploads` on a home workspace: the upload dir IS the data dir.
process.env.CODEMAN_DATA_DIR = uploads; process.env.CODEMAN_DATA_DIR = uploads;
expect(uploadDirs({ workingDir: root })).toEqual([]); expect(await uploadDirs({ workingDir: root })).toEqual([]);
// A data dir pointed inside the upload dir: the recursive delete would take it. // A data dir pointed inside the upload dir: the recursive delete would take it.
process.env.CODEMAN_DATA_DIR = join(uploads, 'state'); process.env.CODEMAN_DATA_DIR = join(uploads, 'state');
expect(uploadDirs({ workingDir: root })).toEqual([]); expect(await uploadDirs({ workingDir: root })).toEqual([]);
// An upload dir inside the data dir, directly and through a symlinked working // An upload dir strictly inside the data dir only ever holds uploads (install.sh
// directory (the upload dir itself is real there). // clones the app to ~/.codeman/app), so it is listed, directly and through a
// symlinked working directory.
process.env.CODEMAN_DATA_DIR = root; process.env.CODEMAN_DATA_DIR = root;
expect(uploadDirs({ workingDir: root })).toEqual([]); expect(await uploadDirs({ workingDir: root })).toEqual([uploads]);
symlinkSync(root, join(parent, 'ws')); symlinkSync(root, join(parent, 'ws'));
expect(uploadDirs({ workingDir: join(parent, 'ws') })).toEqual([]); 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 () => { it('lists nothing for a remote session, whose workingDir is the remote path', async () => {
@@ -125,7 +150,7 @@ describe('uploadDirs', () => {
const old = (Date.now() - 8 * DAY_MS) / 1000; const old = (Date.now() - 8 * DAY_MS) / 1000;
utimesSync(join(workingDir, UPLOADS_DIR, 'paste-1-aa.png'), old, old); utimesSync(join(workingDir, UPLOADS_DIR, 'paste-1-aa.png'), old, old);
const remote = { hostId: 'gpu-box' }; const remote = { hostId: 'gpu-box' };
expect(uploadDirs({ workingDir, remote })).toEqual([]); expect(await uploadDirs({ workingDir, remote })).toEqual([]);
expect(await sweepPasteImagesOnce(sessionsOf(workingDir, remote))).toEqual({ scanned: 0, deleted: 0 }); expect(await sweepPasteImagesOnce(sessionsOf(workingDir, remote))).toEqual({ scanned: 0, deleted: 0 });
expect(existsSync(join(workingDir, UPLOADS_DIR, 'paste-1-aa.png'))).toBe(true); expect(existsSync(join(workingDir, UPLOADS_DIR, 'paste-1-aa.png'))).toBe(true);
}); });