Files
sencho/backend/src/__tests__/filesystem.test.ts
T
Anso 9dbce9c3c7 fix(spawn): attribute ENOMEM and ENOENT-under-memory-pressure spawn failures to host OOM (#1111)
Operators previously saw "spawn docker ENOENT" or "spawn /bin/sh ENOENT" when
the host was under memory pressure, which sent them down a missing-binary
debugging path. Linux libuv's posix_spawn can fail to allocate its argv /
path-search arena under low free memory and surface the underlying ENOMEM
as ENOENT.

Centralizes spawn-error mapping in a new utils/spawnErrors.ts helper:
- Explicit ENOMEM is rewritten to "Out of memory while launching <command>
  (host free memory: X MiB of Y MiB)".
- ENOENT under the 128 MiB free-memory floor is rewritten with the same
  wording plus a "reported as ENOENT under memory pressure" hint.
- ENOENT for docker on a healthy host preserves the existing
  "Docker CLI unavailable on this node" mapping.
- Other errors pass through unchanged.

Applied at the four named offenders: ComposeService.execute(),
ComposeService.captureCompose(), DockerController.getContainersByStack(),
and FileSystemService.getStacks() (which gets an ENOMEM-aware log line
for the scandir failure).

Startup also logs host free/total MiB once and warns when free memory is
below the 128 MiB floor, so the diagnostic surfaces before the first
spawn attempt rather than after it fails.

37 tests cover the mapping function directly and the ComposeService /
FileSystemService integration paths.
2026-05-19 07:27:12 -04:00

130 lines
4.1 KiB
TypeScript

/**
* Unit tests for FileSystemService.deleteStack().
*
* Sencho runs as root inside the container by default, so deleteStack only
* needs to wrap fsPromises.rm and translate ENOENT into a silent no-op.
* Permission errors are surfaced to the caller like any other failure
* (no Docker-helper fallback).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
const { mockRm, mockReaddir } = vi.hoisted(() => ({
mockRm: vi.fn(),
mockReaddir: vi.fn(),
}));
vi.mock('fs', () => ({
promises: {
rm: mockRm,
mkdir: vi.fn(),
readdir: mockReaddir,
readFile: vi.fn(),
writeFile: vi.fn(),
access: vi.fn(),
stat: vi.fn(),
rename: vi.fn(),
copyFile: vi.fn(),
unlink: vi.fn(),
},
}));
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getComposeDir: () => '/test/compose',
getDefaultNodeId: () => 1,
}),
},
}));
import { FileSystemService } from '../services/FileSystemService';
const expectedDir = path.join('/test/compose', 'my-stack');
describe('FileSystemService.deleteStack', () => {
let service: FileSystemService;
beforeEach(() => {
vi.clearAllMocks();
service = FileSystemService.getInstance();
});
it('deletes a stack directory successfully via fsPromises.rm', async () => {
mockRm.mockResolvedValueOnce(undefined);
await service.deleteStack('my-stack');
expect(mockRm).toHaveBeenCalledWith(expectedDir, { recursive: true, force: true });
});
it('silently ignores ENOENT (directory already gone)', async () => {
const err = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
mockRm.mockRejectedValueOnce(err);
await expect(service.deleteStack('gone-stack')).resolves.toBeUndefined();
});
it('throws on EACCES (running as root should make this rare)', async () => {
const err = Object.assign(new Error('permission denied'), { code: 'EACCES' });
mockRm.mockRejectedValueOnce(err);
await expect(service.deleteStack('restricted-stack')).rejects.toThrow(/permission denied/);
});
it('throws on EPERM', async () => {
const err = Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
mockRm.mockRejectedValueOnce(err);
await expect(service.deleteStack('eperm-stack')).rejects.toThrow(/operation not permitted/);
});
it('throws on unexpected errors (e.g. EIO)', async () => {
const err = Object.assign(new Error('disk I/O error'), { code: 'EIO' });
mockRm.mockRejectedValueOnce(err);
await expect(service.deleteStack('io-error-stack')).rejects.toThrow(/disk I\/O error/);
});
});
describe('FileSystemService.getStacks', () => {
let service: FileSystemService;
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
service = FileSystemService.getInstance();
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
warnSpy.mockRestore();
});
it('returns [] and logs ENOMEM-specific message with host free memory', async () => {
const err = Object.assign(
new Error("ENOMEM: not enough memory, scandir '/test/compose'"),
{ code: 'ENOMEM' },
);
mockReaddir.mockRejectedValueOnce(err);
const freememSpy = vi.spyOn(os, 'freemem').mockReturnValue(37 * 1024 * 1024);
try {
const result = await service.getStacks();
expect(result).toEqual([]);
const warning = warnSpy.mock.calls[0]?.[0] as string;
expect(warning).toContain('ENOMEM');
expect(warning).toContain('host free memory: 37 MiB');
expect(warning).toContain('Returning empty list');
} finally {
freememSpy.mockRestore();
}
});
it('returns [] and logs the raw error message for non-ENOMEM errors', async () => {
const err = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
mockReaddir.mockRejectedValueOnce(err);
const result = await service.getStacks();
expect(result).toEqual([]);
const warning = warnSpy.mock.calls[0]?.[0] as string;
expect(warning).toContain('EACCES: permission denied');
expect(warning).not.toContain('host free memory');
});
});