mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
The paste-image route wrote under session.workingDir via ensureUploadDir with no bounded probe, so an upload onto a stalled network mount held one of libuv's few threadpool workers until the mount answered. Probe the workspace with probePathKind past the stall cap before the first disk touch and refuse an unknown mount through describeUnknownPath, the way POST /api/sessions already does.
204 lines
7.7 KiB
TypeScript
204 lines
7.7 KiB
TypeScript
/**
|
|
* @fileoverview Request paths that touch a session's workspace must not freeze the
|
|
* server when its network mount has gone away: `POST /api/sessions` with a
|
|
* `workingDir` on it, `POST /api/quick-start` for a linked case that lives there,
|
|
* and `POST /api/sessions/:id/paste-image` for a live session whose mount went away
|
|
* after creation. Creation must also 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';
|
|
import fastifyMultipart from '@fastify/multipart';
|
|
|
|
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 ctx: ReturnType<typeof createMockRouteContext>;
|
|
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);
|
|
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1 } });
|
|
ctx = createMockRouteContext();
|
|
registerSessionRoutes(app, ctx 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);
|
|
});
|
|
|
|
it('POST /api/sessions/:id/paste-image answers promptly, not frozen, for a workspace on a dead mount', async () => {
|
|
// A live session can be created while its workspace is reachable and later have
|
|
// its mount go away. The probe runs before the multipart body is read, so the
|
|
// refusal lands without buffering the upload.
|
|
ctx._session.workingDir = `${dead.root}/project`;
|
|
|
|
const started = Date.now();
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${ctx._sessionId}/paste-image`,
|
|
headers: { 'x-codeman-csrf': '1', 'content-type': 'multipart/form-data; boundary=b' },
|
|
payload: '--b--\r\n',
|
|
});
|
|
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);
|
|
});
|
|
});
|