Files
Codeman/test/case-path-unreachable.test.ts
T
Codeman maintainer 88f5a43a9f fix(cases): bounded path probe landing fixes (#516)
- hooks-config: a probe the bulk cap refused gets ONE bounded re-probe past the
  cap (probeBeforeTouching), and whatever is still unknown is skipped. The
  per-spawn hook and statusLine helpers used to fall back to an unbounded
  lstat/readFile there, which on a dead workspace never settled and could take
  the last threadpool workers (and hang the boot hook sweep). New test: cap
  engaged, stat/lstat/readFile hanging on two more paths; both helpers return.
- describeUnknownPath()/unknownPathReason(): POST /api/sessions, quick-start and
  GET /api/cases/:name now say a folder was not checked (other mounts are still
  not answering) instead of blaming a healthy folder at the stall ceiling.
  errorCodes unchanged.
- #535 x #516: Create in a custom folder probes the parent through the bounded
  probe before realpath/stat/lstat/readdir touch it; an unknown parent is 422
  OPERATION_FAILED (UNREACHABLE) within the probe timeout. New test.
- Docs: MAX_STALLED default is 2 (follows UV_THREADPOOL_SIZE), CaseInfo
  .unreachable covers a refused probe, the boot sweep skips an unanswering
  workspace, a CLAUDE.md gotcha for bounded probes, verbs.md documents the 422
  (plugin mirror synced), api-reference documents the custom-folder 422.
- Tests: the launcher case-lookup describe is no longer nested in the Grok
  block, and the cap-below-ceiling test no longer depends on an inherited
  UV_THREADPOOL_SIZE / CODEMAN_PATH_PROBE_MAX_STALLED.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 20:00:30 +02:00

92 lines
3.8 KiB
TypeScript

/**
* @fileoverview "Create in a custom folder" (#535) on a parent folder that does not
* answer (#516): `prepareNewCasePath` must ask the bounded path probe first, and give
* up with UNREACHABLE within the probe timeout instead of reaching the realpath / stat /
* lstat / readdir calls that would wait on a hard mount forever.
*
* Only the chosen dead paths hang; everything else is the real filesystem.
* Port: none.
*/
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const probe = vi.hoisted(() => {
// Short probe timeout so a stall costs ~100 ms, read at import.
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '100';
return { dead: '/mnt/dead-nas-case-parent', releases: [] as Array<() => void>, touched: [] as string[] };
});
/** A call on the dead path never settles (a hard mount), until afterEach releases it. */
function hangOnDead<F extends (...a: never[]) => unknown>(name: string, real: F): F {
return ((path: string, ...rest: unknown[]) => {
if (String(path) === probe.dead || String(path).startsWith(probe.dead + '/')) {
probe.touched.push(`${name} ${String(path)}`);
return new Promise((_resolve, reject) => {
probe.releases.push(() => reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })));
});
}
return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest);
}) as unknown as F;
}
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const stat = hangOnDead('stat', actual.stat);
return { ...actual, stat, default: { ...actual, stat } };
});
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const promises = {
...actual.promises,
realpath: hangOnDead('realpath', actual.promises.realpath),
stat: hangOnDead('stat', actual.promises.stat),
lstat: hangOnDead('lstat', actual.promises.lstat),
readdir: hangOnDead('readdir', actual.promises.readdir),
};
return { ...actual, promises, default: { ...actual, promises } };
});
import { prepareNewCasePath } from '../src/web/case-path.js';
const root = realpathSync(mkdtempSync(join(tmpdir(), 'case-path-unreachable-')));
const ctx = { home: join(root, 'home'), dataDir: join(root, 'home', '.codeman'), casesDirs: [join(root, 'cases')] };
afterEach(async () => {
probe.releases.splice(0).forEach((release) => release());
probe.touched.length = 0;
await new Promise((r) => setTimeout(r, 0));
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
describe('prepareNewCasePath on a parent folder that does not answer', () => {
it('answers UNREACHABLE within the probe timeout and never touches the path unbounded', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await Promise.race([
prepareNewCasePath(`${probe.dead}/new-case`, ctx),
new Promise<'hung'>((resolve) => setTimeout(() => resolve('hung'), 2_000)),
]);
warn.mockRestore();
expect(result).toMatchObject({ ok: false, code: 'UNREACHABLE' });
expect((result as { reason: string }).reason).toBe(
`The parent folder is not responding or not readable: ${probe.dead}`
);
// Only the bounded probe's own stat reached the dead mount.
expect(probe.touched).toEqual([`stat ${probe.dead}`]);
});
it('still reports a parent that definitely does not exist as NOT_FOUND', async () => {
expect(await prepareNewCasePath(join(root, 'no-such-parent', 'new-case'), ctx)).toMatchObject({
ok: false,
code: 'NOT_FOUND',
});
});
});