fix(cases): tell an unreachable path from an absent one, scope the stall cap

The bounded path probe answered "absent" both when a path did not exist and
when it simply did not answer, so a stalled linked case 404'd and the Run
button scaffolded a stray local case over it, and two stalled paths anywhere
made every unrelated path read as absent (hooks skipped, statusLine
overridden, the clone warning lost).

- probePath()/probePathKind() are tri-state: present (or directory/file),
  absent (ENOENT/ENOTDIR only) and unknown (timeout, other errors, refusal).
  boundedPathExists() stays as the display-only boolean.
- A stalled path takes only its own mount out of probing (deepest mount
  point from /proc/self/mounts, never /; just the path itself when there is
  no mount table). Unrelated paths keep probing. The process-wide cap is a
  backstop that answers unknown, and a single-path user request can probe
  past it ({ pastCap: true }), still bounded and still recorded as stalled.
  One console.warn when a path first stalls and one when the cap engages.
- GET /api/cases/:name keeps NOT_FOUND for definite absence only. An
  unreachable linked case answers with its registered path and
  unreachable: true; a local one answers OPERATION_FAILED. runClaude and
  runShell create a case only on errorCode NOT_FOUND. The case list keeps an
  unreachable linked case, marked unreachable, instead of dropping it, and
  fix-plan reports an unreadable plan as an error, not "no plan".
- applyWorkspaceHooks and the statusLine helpers skip only a workspace that
  is absent or on the stalled mount; a capacity refusal no longer stops
  hooks being installed elsewhere, and an unreadable settings file never
  lets the exporter override a user's own statusLine.
- The clone flow's repo-settings warning is back on its synchronous check,
  and stripCaseEnvKeys uses pathExistsForWrite.
- POST /api/sessions (workingDir) and POST /api/quick-start (case folder)
  probe with the bounded probe instead of statSync/existsSync. Missing and
  non-directory keep INVALID_INPUT; unknown is OPERATION_FAILED, and
  quick-start never scaffolds over a folder that did not answer.
- PATH_PROBE_TIMEOUT_MS and MAX_STALLED_PATH_PROBES move to
  src/config/path-probe.ts, overridable via CODEMAN_PATH_PROBE_TIMEOUT_MS
  (default 1500) and CODEMAN_PATH_PROBE_MAX_STALLED (default 3), and are
  documented in the Settings Reference.
- The probe is exported from the utils barrel and imported from there.
This commit is contained in:
Aamer Akhter
2026-10-04 20:30:40 -04:00
parent 00b935abe6
commit d1bfbb4fcf
15 changed files with 1028 additions and 126 deletions
+193 -39
View File
@@ -1,103 +1,257 @@
/**
* @fileoverview Tests for boundedPathExists (src/utils/bounded-path-probe.ts):
* @fileoverview Tests for the bounded path probe (src/utils/bounded-path-probe.ts):
* a stat() that never settles (an unreachable hard network mount) must not hold
* the caller past the timeout, must not be re-issued while it is still pending,
* and must not let stalled probes pile up in libuv's shared threadpool.
* the caller past the timeout, must read as "unknown" rather than "absent", must
* not be re-issued while it is still pending, must not let stalled probes pile up
* in libuv's shared threadpool, and must not make unrelated healthy paths unknown.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('node:fs/promises', () => ({
default: { stat: vi.fn() },
}));
// The kernel mount table the probe scopes a stall by. `/mnt/nas` and `/mnt/nas b`
// (a mount point with a space, octal-escaped in the table) are network mounts;
// everything else sits on the root filesystem. `null` = no table (not Linux).
const mounts = vi.hoisted(() => ({
table: null as string | null,
default: [
'sysfs /sys sysfs rw 0 0',
'/dev/sda1 / ext4 rw 0 0',
'nas:/export /mnt/nas nfs rw,hard 0 0',
'nas:/other /mnt/nas\\040b nfs rw,hard 0 0',
'',
].join('\n'),
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const readFileSync = ((path: unknown, ...rest: unknown[]) => {
if (String(path) === '/proc/self/mounts') {
if (mounts.table === null) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
return mounts.table;
}
return (actual.readFileSync as (...a: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.readFileSync;
return { ...actual, readFileSync, default: { ...actual, readFileSync } };
});
import fs from 'node:fs/promises';
import { boundedPathExists, PROBE_TIMEOUT_MS } from '../src/utils/bounded-path-probe.js';
import { boundedPathExists, isNearStalledPath, probePath, probePathKind } from '../src/utils/bounded-path-probe.js';
import { MAX_STALLED_PATH_PROBES, PATH_PROBE_TIMEOUT_MS } from '../src/config/path-probe.js';
const stat = vi.mocked(fs.stat);
const dirStats = { isDirectory: () => true } as never;
const fileStats = { isDirectory: () => false } as never;
let releases: Map<string, () => void>;
let warn: ReturnType<typeof vi.spyOn>;
/**
* Make the first stat() of each given path hang until released (the mount is
* down); every later stat, and every other path, answers "exists".
* down); every later stat, and every other path, answers "a directory exists".
*/
function hangOn(paths: string[]): Map<string, () => void> {
const releases = new Map<string, () => void>();
stat.mockImplementation((path) => {
if (!paths.includes(String(path)) || releases.has(String(path))) return Promise.resolve({} as never);
if (!paths.includes(String(path)) || releases.has(String(path))) return Promise.resolve(dirStats);
return new Promise((resolve) => {
releases.set(String(path), () => resolve({} as never));
releases.set(String(path), () => resolve(dirStats));
});
});
return releases;
}
afterEach(() => {
vi.useRealTimers();
stat.mockReset();
/** Start probes for `paths` and let them time out, leaving each one stalled. */
async function stall(paths: string[]): Promise<void> {
const pending = paths.map((p) => probePath(p));
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await Promise.all(pending)).toEqual(paths.map(() => 'unknown'));
}
beforeEach(() => {
mounts.table = mounts.default;
releases = new Map();
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
describe('boundedPathExists', () => {
it('reports an existing path as present and a missing one as absent', async () => {
afterEach(async () => {
// Settle every stalled stat so module state does not leak into the next test.
releases.forEach((release) => release());
if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(0);
else await new Promise((r) => setTimeout(r, 0));
vi.useRealTimers();
stat.mockReset();
warn.mockRestore();
});
describe('probePath', () => {
it('tells present, absent and unreadable apart', async () => {
stat.mockImplementation(async (path) => {
if (String(path) === '/present') return {} as never;
if (String(path) === '/present') return dirStats;
if (String(path) === '/eio') throw Object.assign(new Error('EIO'), { code: 'EIO' });
if (String(path) === '/notdir/child') throw Object.assign(new Error('ENOTDIR'), { code: 'ENOTDIR' });
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
expect(await probePath('/present')).toBe('present');
expect(await probePath('/missing')).toBe('absent');
expect(await probePath('/notdir/child')).toBe('absent');
// A soft mount that gave up answers EIO: that is not proof the path is gone.
expect(await probePath('/eio')).toBe('unknown');
expect(await boundedPathExists('/present')).toBe(true);
expect(await boundedPathExists('/missing')).toBe(false);
expect(await boundedPathExists('/eio')).toBe(false);
});
it('answers false after the timeout when stat never settles, and does not re-probe until it does', async () => {
vi.useFakeTimers();
const releases = hangOn(['/mnt/stalled/case']);
it('reports whether a present path is a directory', async () => {
stat.mockImplementation(async (path) => (String(path) === '/dir' ? dirStats : fileStats));
expect(await probePathKind('/dir')).toBe('directory');
expect(await probePathKind('/file')).toBe('file');
});
const result = boundedPathExists('/mnt/stalled/case');
await vi.advanceTimersByTimeAsync(PROBE_TIMEOUT_MS);
expect(await result).toBe(false);
it('answers unknown (not absent) after the timeout, and does not re-probe until the stat settles', async () => {
vi.useFakeTimers();
hangOn(['/mnt/stalled/case']);
const result = probePath('/mnt/stalled/case');
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await result).toBe('unknown');
// A second caller gets the cached verdict immediately, without another stat.
expect(await boundedPathExists('/mnt/stalled/case')).toBe(false);
expect(await probePath('/mnt/stalled/case')).toBe('unknown');
expect(stat).toHaveBeenCalledTimes(1);
// Once the mount answers, the path is probed afresh.
releases.get('/mnt/stalled/case')!();
await vi.advanceTimersByTimeAsync(0);
expect(await boundedPathExists('/mnt/stalled/case')).toBe(true);
expect(await probePath('/mnt/stalled/case')).toBe('present');
expect(stat).toHaveBeenCalledTimes(2);
});
it('shares one in-flight stat between concurrent callers of the same path', async () => {
const releases = hangOn(['/slow']);
const a = boundedPathExists('/slow');
hangOn(['/slow']);
const a = probePath('/slow');
const b = boundedPathExists('/slow');
expect(stat).toHaveBeenCalledTimes(1);
releases.get('/slow')!();
expect(await a).toBe(true);
expect(await a).toBe('present');
expect(await b).toBe(true);
});
it('does not give concurrent healthy probes a false negative', async () => {
stat.mockImplementation(async () => ({}) as never);
stat.mockImplementation(async () => dirStats);
const results = await Promise.all(['/a', '/b', '/c', '/d', '/e'].map((p) => boundedPathExists(p)));
expect(results).toEqual([true, true, true, true, true]);
});
it('stops issuing new stats once stalled probes would tie up the threadpool', async () => {
it('still probes a healthy path as present while two unrelated paths are stalled', async () => {
vi.useFakeTimers();
const releases = hangOn(['/mnt/stalled/one', '/mnt/stalled/two']);
hangOn(['/mnt/nas-a/project', '/mnt/nas-b/project']);
await stall(['/mnt/nas-a/project', '/mnt/nas-b/project']);
const first = boundedPathExists('/mnt/stalled/one');
const second = boundedPathExists('/mnt/stalled/two');
await vi.advanceTimersByTimeAsync(PROBE_TIMEOUT_MS);
expect(await first).toBe(false);
expect(await second).toBe(false);
expect(await probePath('/home/user/codeman-cases/healthy')).toBe('present');
expect(await boundedPathExists('/home/user/codeman-cases/healthy/CLAUDE.md')).toBe(true);
expect(isNearStalledPath('/home/user/codeman-cases/healthy')).toBe(false);
});
// Both slots are held by stats that never returned: refuse a third.
expect(await boundedPathExists('/healthy/three')).toBe(false);
expect(stat).toHaveBeenCalledTimes(2);
it('answers unknown, without a stat, for paths near a stalled one', async () => {
vi.useFakeTimers();
hangOn(['/mnt/nas/project-one']);
await stall(['/mnt/nas/project-one']);
stat.mockClear();
// Its own files, and a sibling linked case on the same mount.
expect(await probePath('/mnt/nas/project-one/CLAUDE.md')).toBe('unknown');
expect(await probePath('/mnt/nas/project-two')).toBe('unknown');
expect(isNearStalledPath('/mnt/nas/project-two/.claude/settings.local.json')).toBe(true);
expect(stat).not.toHaveBeenCalled();
releases.get('/mnt/nas/project-one')!();
await vi.advanceTimersByTimeAsync(0);
expect(isNearStalledPath('/mnt/nas/project-two')).toBe(false);
expect(await probePath('/mnt/nas/project-two')).toBe('present');
});
it('reads octal-escaped mount points from the table', async () => {
vi.useFakeTimers();
hangOn(['/mnt/nas b/one']);
await stall(['/mnt/nas b/one']);
expect(isNearStalledPath('/mnt/nas b/two')).toBe(true);
expect(isNearStalledPath('/mnt/nas/two')).toBe(false);
});
it('never takes the root filesystem down with a stalled path on it, only that path', async () => {
vi.useFakeTimers();
hangOn(['/srv/projects/stuck']);
await stall(['/srv/projects/stuck']);
expect(await probePath('/srv/projects/stuck/CLAUDE.md')).toBe('unknown');
expect(await probePath('/srv/projects/other')).toBe('present');
expect(await probePath('/home/user/codeman-cases/one')).toBe('present');
});
it('narrows a stall to the stalled path when there is no mount table', async () => {
vi.useFakeTimers();
mounts.table = null;
hangOn(['/mnt/nas/project-one']);
await stall(['/mnt/nas/project-one']);
expect(await probePath('/mnt/nas/project-one/CLAUDE.md')).toBe('unknown');
expect(await probePath('/mnt/nas/project-two')).toBe('present');
});
it('refuses new stats once stalled probes would tie up the threadpool, answering unknown', async () => {
vi.useFakeTimers();
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/dead-${i}/case`);
hangOn(dead);
await stall(dead);
stat.mockClear();
// Every slot is held by a stat that never returned: refuse another, but never
// claim the path is absent.
expect(await probePath('/healthy/elsewhere')).toBe('unknown');
expect(stat).not.toHaveBeenCalled();
// Once the stalled stats settle, probing resumes normally.
releases.forEach((release) => release());
await vi.advanceTimersByTimeAsync(0);
expect(await boundedPathExists('/healthy/three')).toBe(true);
expect(stat).toHaveBeenCalledTimes(3);
expect(await probePath('/healthy/elsewhere')).toBe('present');
expect(stat).toHaveBeenCalledTimes(1);
});
it('lets a pastCap probe through the cap, still bounded and still recorded as stalled', async () => {
vi.useFakeTimers();
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/full-${i}/case`);
hangOn([...dead, '/mnt/another-dead/case']);
await stall(dead);
stat.mockClear();
expect(await probePath('/healthy/explicit', { pastCap: true })).toBe('present');
const hung = probePath('/mnt/another-dead/case', { pastCap: true });
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await hung).toBe('unknown');
// A retry is answered from the stall record, not with another stat.
expect(await probePath('/mnt/another-dead/case', { pastCap: true })).toBe('unknown');
expect(stat).toHaveBeenCalledTimes(2);
});
it('warns once when a path first stalls and once when the cap engages', async () => {
vi.useFakeTimers();
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/gone-${i}/case`);
hangOn(dead);
await stall([dead[0]]);
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toContain('/mnt/gone-0/case');
// Asking again about the same stalled path does not warn again.
await probePath(dead[0]);
expect(warn).toHaveBeenCalledTimes(1);
await stall(dead.slice(1));
warn.mockClear();
await probePath('/healthy/one');
await probePath('/healthy/two');
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toMatch(/stalled/i);
});
});
+43 -1
View File
@@ -17,7 +17,28 @@
* Port: N/A (app.inject).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
// Unreachable-mount seam: `stat()` of a path under this root never settles (a hard
// network mount that went away), so the bounded path probe can be driven to its
// stall cap. Every other stat is the real one. The short timeout is read at import.
const deadMount = vi.hoisted(() => {
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '200';
return { root: '/mnt/codeman-clone-test-dead', releases: [] as Array<() => void> };
});
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const stat = ((path: string, ...rest: unknown[]) => {
if (String(path).startsWith(deadMount.root + '/')) {
return new Promise((resolve) => deadMount.releases.push(() => resolve({} as never)));
}
return (actual.stat as (...a: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.stat;
return { ...actual, stat, default: { ...actual, stat } };
});
afterAll(() => {
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { execFileSync } from 'node:child_process';
@@ -38,6 +59,8 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { isGitAvailable } from '../../src/git-clone.js';
import { probePath } from '../../src/utils/index.js';
import { MAX_STALLED_PATH_PROBES } from '../../src/config/path-probe.js';
const CASES_DIR = join(homedir(), 'codeman-cases');
const gitPresent = isGitAvailable();
@@ -252,6 +275,25 @@ describe.skipIf(!gitPresent)('POST /api/cases/clone — real clone', () => {
expect(body.data.warnings.join(' ')).toMatch(/ships its own \.claude/);
});
it('still warns about repo-supplied .claude settings while unrelated mounts are unreachable', async () => {
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `${deadMount.root}/nas-${i}/project`);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
// Engage the probe's stall cap: every new bounded probe is now refused.
expect(await Promise.all(dead.map((p) => probePath(p)))).toEqual(dead.map(() => 'unknown'));
created.push('warns-under-cap');
const res = await clone({ name: 'warns-under-cap', repository: origin });
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.warnings.join(' ')).toMatch(/ships its own \.claude/);
} finally {
deadMount.releases.splice(0).forEach((release) => release());
await new Promise((r) => setTimeout(r, 0));
warn.mockRestore();
}
});
it('installs Codeman hooks alongside whatever the repo shipped', async () => {
created.push('hooked');
await clone({ name: 'hooked', repository: origin });
+93 -3
View File
@@ -13,13 +13,24 @@
* behavior matches production exactly).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { probePath } from '../../src/utils/index.js';
import { MAX_STALLED_PATH_PROBES } from '../../src/config/path-probe.js';
// A short path-probe timeout keeps the unreachable-mount tests quick. Read when the
// probe's config module is first imported, so it is set before any import runs.
vi.hoisted(() => {
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '300';
});
afterAll(() => {
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
// Mock filesystem modules
vi.mock('node:fs', async (importOriginal) => {
@@ -251,8 +262,19 @@ describe('case-routes', () => {
expect(res.statusCode).toBe(200);
expect(elapsed).toBeLessThan(BLOCK_MS - 1_000);
// The unreachable case is left out rather than holding the list hostage.
expect(JSON.parse(res.body).data).toEqual([]);
// The unreachable case is listed as such rather than holding the list
// hostage, or vanishing as though it had been deleted.
expect(JSON.parse(res.body).data).toEqual([
{
name: 'linked-nfs',
path: stalledPath,
hasClaudeMd: false,
linked: true,
location: 'linked-local',
unreachable: true,
},
]);
await new Promise((r) => setTimeout(r, 0)); // let the released stat clear its stall
});
});
@@ -714,6 +736,64 @@ describe('case-routes', () => {
expect(body.data.name).toBe('regular-case');
});
it('answers a linked case on an unreachable mount with its registered path, not NOT_FOUND', async () => {
// The timeout path: the mount does not answer at all.
const stalledPath = '/mnt/unreachable/linked-get';
mockedReadFile.mockResolvedValue(JSON.stringify({ 'linked-get': stalledPath }) as never);
let release: (() => void) | undefined;
mockedStat.mockImplementation((p) => {
if (String(p) !== stalledPath) {
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
}
return new Promise((resolve) => {
release = () => resolve({ isDirectory: () => true } as never);
});
});
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/linked-get' });
release?.();
await new Promise((r) => setTimeout(r, 0)); // let the released stat clear its stall
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data).toMatchObject({ name: 'linked-get', path: stalledPath, linked: true, unreachable: true });
});
it('still answers a healthy case while unrelated mounts are stalled past the cap', async () => {
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/dead-${i}/linked`);
const releases: Array<() => void> = [];
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedStat.mockImplementation((p) => {
if (dead.includes(String(p))) {
return new Promise((resolve) => releases.push(() => resolve({ isDirectory: () => true } as never)));
}
return Promise.resolve({ isDirectory: () => true } as never);
});
expect(await Promise.all(dead.map((p) => probePath(p)))).toEqual(dead.map(() => 'unknown'));
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/healthy-local' });
releases.forEach((release) => release());
await new Promise((r) => setTimeout(r, 0));
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data).toMatchObject({ name: 'healthy-local' });
expect(JSON.parse(res.body).data.unreachable).toBeUndefined();
});
it('answers a local case it cannot read with a non-NOT_FOUND error', async () => {
// A soft mount that gave up (EIO) is not proof the case is gone, and the Run
// button creates a case on NOT_FOUND.
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedStat.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' }));
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/eio-case' });
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('OPERATION_FAILED');
expect(res.statusCode).not.toBe(404);
});
it('returns error when case not found anywhere', async () => {
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedExistsSync.mockReturnValue(false);
@@ -748,6 +828,16 @@ describe('case-routes', () => {
expect(body.data.todos).toEqual([]);
});
it('reports an unreadable fix plan as an error, not as "no plan"', async () => {
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedStat.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' }));
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/my-case/fix-plan' });
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('OPERATION_FAILED');
});
it('parses fix plan with todos and stats', async () => {
const fixPlanContent = [
'# Fix Plan',
@@ -0,0 +1,174 @@
/**
* @fileoverview Session creation must not freeze the server on a workspace whose
* network mount has gone away (`POST /api/sessions` with a `workingDir` on it, and
* `POST /api/quick-start` for a linked case that lives there), and must not treat
* "did not answer" as "does not exist" (quick-start would scaffold a fresh case
* over the top of where the real one is mounted).
*
* A hard mount that stopped answering is simulated two ways, matching how each
* API behaves on one: a synchronous probe (`existsSync`/`statSync`/`mkdirSync`)
* busy-waits, freezing the event loop, and an async `stat()` never settles.
*
* Uses app.inject(), so no real HTTP port is needed.
*/
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
const dead = vi.hoisted(() => {
// Short probe timeout so a stalled stat costs ~200 ms here. Read at import.
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '200';
return {
root: '/mnt/codeman-test-dead-mount',
blockMs: 3_000,
syncTouches: [] as string[],
releases: [] as Array<() => void>,
};
});
function onDeadMount(path: unknown): boolean {
const p = String(path);
return p === dead.root || p.startsWith(dead.root + '/');
}
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const freezeOn =
<T extends (...args: never[]) => unknown>(fn: T) =>
(...args: Parameters<T>): ReturnType<T> => {
if (onDeadMount(args[0])) {
dead.syncTouches.push(String(args[0]));
const until = Date.now() + dead.blockMs;
while (Date.now() < until) {
// spin: the event loop is frozen for as long as the mount does not answer
}
throw Object.assign(new Error('EIO'), { code: 'EIO' });
}
return fn(...args) as ReturnType<T>;
};
const existsSync = freezeOn(actual.existsSync);
const statSync = freezeOn(actual.statSync as (...args: never[]) => unknown);
const mkdirSync = freezeOn(actual.mkdirSync as (...args: never[]) => unknown);
return {
...actual,
existsSync,
statSync,
mkdirSync,
default: { ...actual, existsSync, statSync, mkdirSync },
};
});
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const stat = ((path: string, ...rest: unknown[]) => {
if (onDeadMount(path)) {
return new Promise((_resolve, reject) => {
dead.releases.push(() => reject(Object.assign(new Error('EIO'), { code: 'EIO' })));
});
}
return (actual.stat as (...a: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.stat;
return { ...actual, stat, default: { ...actual, stat } };
});
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createMockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { dataPath } from '../../src/config/instance.js';
describe('session creation on an unreachable mount', () => {
let app: FastifyInstance;
let scratch: string;
let warn: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
dead.syncTouches.length = 0;
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
scratch = await mkdtemp(join(tmpdir(), 'codeman-unreachable-create-'));
app = Fastify({ logger: false });
await app.register(fastifyCookie);
registerSessionRoutes(app, createMockRouteContext() as never);
installRouteErrorHandler(app);
await app.ready();
});
afterEach(async () => {
await app.close();
dead.releases.splice(0).forEach((release) => release());
await new Promise((r) => setTimeout(r, 0));
await rm(scratch, { recursive: true, force: true });
await rm(dataPath('linked-cases.json'), { force: true });
warn.mockRestore();
});
afterAll(() => {
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
it('POST /api/sessions answers promptly, and not as "does not exist", for a workingDir on a dead mount', async () => {
const started = Date.now();
const res = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'dead-mount', mode: 'shell', workingDir: `${dead.root}/project` },
});
const elapsed = Date.now() - started;
expect(elapsed).toBeLessThan(dead.blockMs - 1_000);
expect(dead.syncTouches).toEqual([]);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('OPERATION_FAILED');
expect(body.error).toMatch(/not responding/i);
});
it('POST /api/sessions keeps INVALID_INPUT for a missing workingDir and for a file', async () => {
const file = join(scratch, 'a-file.txt');
await writeFile(file, 'x');
const missing = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'missing', mode: 'shell', workingDir: join(scratch, 'nope') },
});
expect(JSON.parse(missing.body)).toMatchObject({
success: false,
errorCode: 'INVALID_INPUT',
error: 'workingDir does not exist',
});
const notDir = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'file', mode: 'shell', workingDir: file },
});
expect(JSON.parse(notDir.body)).toMatchObject({
success: false,
errorCode: 'INVALID_INPUT',
error: 'workingDir is not a directory',
});
});
it('POST /api/quick-start refuses, promptly and without scaffolding, a linked case on a dead mount', async () => {
await writeFile(dataPath('linked-cases.json'), JSON.stringify({ 'nas-linked': `${dead.root}/linked` }));
const started = Date.now();
const res = await app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'nas-linked', mode: 'shell' },
});
const elapsed = Date.now() - started;
expect(elapsed).toBeLessThan(dead.blockMs - 1_000);
// Neither probed nor created synchronously on the dead mount.
expect(dead.syncTouches).toEqual([]);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('OPERATION_FAILED');
expect(body.error).toMatch(/not responding/i);
});
});
+62
View File
@@ -1227,4 +1227,66 @@ describe('Grok quick start', () => {
expect(names).toEqual(['w1-grok-case', 'w2-grok-case', 'w3-grok-case']);
expect(selected).toEqual(['sess-gk-0']);
});
describe('case lookup before a local launch', () => {
function loadLaunchHarness(caseAnswer: Record<string, unknown>) {
const elements: Record<string, any> = {
quickStartCase: { value: 'nas-case' },
shellCount: { value: '1' },
tabCount: { value: '1' },
};
const requests: Array<{ url: string; method?: string }> = [];
const written: string[] = [];
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: (id: string) => elements[id] ?? null },
fetch: async (url: string, init?: { method?: string }) => {
requests.push({ url, method: init?.method });
if (url === '/api/cases/nas-case') return { json: async () => caseAnswer };
if (url === '/api/cases' && init?.method === 'POST') {
return {
json: async () => ({
success: true,
data: { case: { name: 'nas-case', path: '/home/u/codeman-cases/nas-case' } },
}),
};
}
// Anything past the case lookup is out of scope here: stop the launch.
throw new Error(`stop: ${url}`);
},
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.terminal = { clear: () => {}, writeln: (line: string) => written.push(line), focus: () => {} };
app.sessions = new Map();
app.cases = [];
app.getTerminalDimensions = () => null;
app._readTabCount = () => 1;
app.loadAppSettingsFromStorage = () => ({});
app.getCaseSettings = () => ({});
return { app, requests, written };
}
const unreachable = { success: false, error: 'Case folder is not responding', errorCode: 'OPERATION_FAILED' };
const missing = { success: false, error: 'Case not found', errorCode: 'NOT_FOUND' };
for (const launcher of ['runClaude', 'runShell'] as const) {
it(`${launcher} never creates a case when the lookup could not tell whether it exists`, async () => {
const { app, requests, written } = loadLaunchHarness(unreachable);
await app[launcher]();
expect(requests.some((r) => r.url === '/api/cases' && r.method === 'POST')).toBe(false);
expect(written.join('\n')).toContain('Case folder is not responding');
});
it(`${launcher} creates the case when the lookup says it does not exist`, async () => {
const { app, requests } = loadLaunchHarness(missing);
await app[launcher]();
expect(requests.some((r) => r.url === '/api/cases' && r.method === 'POST')).toBe(true);
});
}
});
});
@@ -0,0 +1,145 @@
/**
* @fileoverview How the workspace hook and statusLine helpers in hooks-config.ts
* read an "unknown" answer from the bounded path probe. A dead network mount
* elsewhere on the machine (enough of them to engage the probe's stall cap) must
* not stop Codeman's hooks from being installed in a healthy workspace, and must
* not let the plan-usage exporter be injected over a user's own statusLine. A
* workspace that IS on the dead mount is skipped without hanging the caller.
*
* Real temp directories; only `stat()` of the chosen dead paths is made to hang.
* Port: none.
*/
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const probe = vi.hoisted(() => {
// Short probe timeout so the stalls below cost ~100 ms each, read at import.
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '100';
return { dead: new Set<string>(), releases: [] as Array<() => void> };
});
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const stat = ((path: string, ...rest: unknown[]) => {
for (const dead of probe.dead) {
if (String(path) === dead || String(path).startsWith(dead + '/')) {
return new Promise((resolve, reject) => {
probe.releases.push(() => reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })));
void resolve;
});
}
}
return (actual.stat as (...a: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.stat;
return { ...actual, stat, default: { ...actual, stat } };
});
import { applyWorkspaceHooks, resolveStatusLineCliCommand, stripCaseEnvKeys } from '../src/hooks-config.js';
import { probePath } from '../src/utils/index.js';
import { MAX_STALLED_PATH_PROBES } from '../src/config/path-probe.js';
const root = mkdtempSync(join(tmpdir(), 'codeman-unreachable-mount-'));
/** Stall `count` paths on unrelated "mounts" until afterEach releases them. */
async function stallUnrelatedMounts(count: number): Promise<void> {
const paths = Array.from({ length: count }, (_, i) => `/mnt/dead-nas-${i}/project`);
paths.forEach((p) => probe.dead.add(p));
expect(await Promise.all(paths.map((p) => probePath(p)))).toEqual(paths.map(() => 'unknown'));
}
let warn: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(async () => {
probe.dead.clear();
probe.releases.splice(0).forEach((release) => release());
await new Promise((r) => setTimeout(r, 0));
warn.mockRestore();
});
afterAll(() => {
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
describe('workspace helpers while other mounts are unreachable', () => {
it('installs hooks in a healthy workspace while the stall cap is engaged', async () => {
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
const workspace = join(root, 'healthy-a');
mkdirSync(workspace);
await applyWorkspaceHooks(workspace, true);
const settings = join(workspace, '.claude', 'settings.local.json');
expect(existsSync(settings)).toBe(true);
expect(readFileSync(settings, 'utf-8')).toContain('/api/hook-event');
});
it('installs hooks in a healthy workspace while two unrelated paths are stalled', async () => {
await stallUnrelatedMounts(2);
const workspace = join(root, 'healthy-b');
mkdirSync(workspace);
await applyWorkspaceHooks(workspace, true);
expect(existsSync(join(workspace, '.claude', 'settings.local.json'))).toBe(true);
});
it('skips, without hanging, a workspace that sits on the dead mount', async () => {
const workspace = '/mnt/dead-nas-x/project';
probe.dead.add('/mnt/dead-nas-x');
expect(await probePath(workspace)).toBe('unknown');
const started = Date.now();
await applyWorkspaceHooks(join('/mnt/dead-nas-x', 'project'), true);
expect(Date.now() - started).toBeLessThan(1_000);
expect(
warn.mock.calls.some(
(c: unknown[]) => /hooks/i.test(String(c[0])) && String(c[0]).includes('/mnt/dead-nas-x/project')
)
).toBe(true);
});
it('removes a superseded env key from a healthy workspace while the stall cap is engaged', async () => {
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
const workspace = join(root, 'strip-env');
mkdirSync(join(workspace, '.claude'), { recursive: true });
const settings = join(workspace, '.claude', 'settings.local.json');
writeFileSync(settings, JSON.stringify({ env: { CLAUDE_CODE_STALE: '1', USER_KEEP: '2' } }));
await stripCaseEnvKeys(workspace, ['CLAUDE_CODE_STALE']);
expect(JSON.parse(readFileSync(settings, 'utf-8')).env).toEqual({ USER_KEEP: '2' });
});
it("never injects the exporter over a user's own statusLine while the cap is engaged", async () => {
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
const workspace = join(root, 'own-statusline');
mkdirSync(join(workspace, '.claude'), { recursive: true });
writeFileSync(
join(workspace, '.claude', 'settings.local.json'),
JSON.stringify({ statusLine: { type: 'command', command: 'my-own-statusline' } })
);
expect(await resolveStatusLineCliCommand(workspace, true)).toBeUndefined();
});
it('still injects the exporter in a healthy workspace without one while the cap is engaged', async () => {
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
const workspace = join(root, 'no-statusline');
mkdirSync(workspace);
expect(await resolveStatusLineCliCommand(workspace, true)).toMatch(/statusline-exporter\.sh$/);
});
it('does not inject the exporter into a workspace on the dead mount', async () => {
probe.dead.add('/mnt/dead-nas-y');
expect(await probePath('/mnt/dead-nas-y/project')).toBe('unknown');
expect(await resolveStatusLineCliCommand('/mnt/dead-nas-y/project', true)).toBeUndefined();
});
});