mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
feat(local-model): the pinned runtime, its supervisor and the runtime client
runtime.ts: the runtime is pinned in a compiled constant (version, URL, size, sha256). The archive is streamed to a .partial file while it is hashed, continued with a Range request, retried at most 3 times, and the hash is compared before anything is unpacked; a mismatch deletes the file. It is unpacked with /usr/bin/tar into a temporary folder and renamed. The supervisor runs the runtime as a non-detached child with an environment built from an allowlist by the pure runtimeEnv(), on a port chosen once by binding. It restarts the child at most 3 times, stops it with SIGTERM, adopts or replaces its own orphan on the port and answers port-in-use for any other listener. parseInferenceComputeTotal() reads the device total the runtime logs when it starts. Under vitest nothing spawns or fetches. ollama-client.ts: the pull is parsed incrementally by a pure parser (a 23 GB pull is about 1.4 million lines), with up to 5 retries on an error line or a cut stream. verifyModel() loads the model with one tiny chat, checks what the runtime reports (all on the GPU, the chosen context) and makes one tool probe; each failure is a code. developer-tools.ts: only /usr/bin/xcode-select -p, bounded, darwin only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
492df776d0
commit
2af413aa9b
7 files changed
+4525
-60
No files matched your search
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* @fileoverview Tests for src/local-model/developer-tools.ts: whether this Mac has the
|
||||
* developer tools a coding agent calls.
|
||||
*
|
||||
* Pinned: the ONLY command it ever runs is `/usr/bin/xcode-select -p` (never `git` or
|
||||
* `python3`, which open a system window on a Mac without the tools), with a timeout; exit 0
|
||||
* is `present`, any other exit is `missing`, no answer is `unknown`; off macOS nothing is run
|
||||
* at all; nothing it is given can make it throw; and an answer is reused for a minute, so a
|
||||
* window that polls once a second does not spawn once a second.
|
||||
*
|
||||
* Port: N/A (the runner is injected; nothing is spawned).
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
checkDeveloperTools,
|
||||
createDeveloperToolsCheck,
|
||||
DEVELOPER_TOOLS_TIMEOUT_MS,
|
||||
resetDeveloperToolsCache,
|
||||
} from '../src/local-model/developer-tools.js';
|
||||
|
||||
type Run = (file: string, args: readonly string[], timeoutMs: number) => Promise<number | null>;
|
||||
|
||||
/** A runner that answers `code` and records how it was called. */
|
||||
function runner(code: number | null): { run: Run; calls: [string, readonly string[], number][] } {
|
||||
const calls: [string, readonly string[], number][] = [];
|
||||
return {
|
||||
calls,
|
||||
run: async (file, args, timeoutMs) => {
|
||||
calls.push([file, args, timeoutMs]);
|
||||
return code;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetDeveloperToolsCache();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('checkDeveloperTools', () => {
|
||||
it('runs exactly one command: /usr/bin/xcode-select -p, bounded', async () => {
|
||||
const { run, calls } = runner(0);
|
||||
expect(await checkDeveloperTools({ platform: 'darwin', run })).toBe('present');
|
||||
expect(calls).toEqual([['/usr/bin/xcode-select', ['-p'], DEVELOPER_TOOLS_TIMEOUT_MS]]);
|
||||
expect(DEVELOPER_TOOLS_TIMEOUT_MS).toBe(2_000);
|
||||
});
|
||||
|
||||
it('never names git, python or xcrun, whatever the answer is', async () => {
|
||||
for (const code of [0, 1, 2, null]) {
|
||||
const { run, calls } = runner(code);
|
||||
await checkDeveloperTools({ platform: 'darwin', run });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0][0]).toBe('/usr/bin/xcode-select');
|
||||
expect(JSON.stringify(calls)).not.toMatch(/git|python|xcrun/);
|
||||
}
|
||||
});
|
||||
|
||||
it('reads exit 0 as present and any other exit as missing', async () => {
|
||||
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(0).run })).toBe('present');
|
||||
// A Mac without the tools: "unable to get active developer directory", exit 2.
|
||||
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(2).run })).toBe('missing');
|
||||
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(1).run })).toBe('missing');
|
||||
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(127).run })).toBe('missing');
|
||||
});
|
||||
|
||||
it('reads no answer as unknown: a timeout, a command that could not be started, a runner that throws', async () => {
|
||||
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(null).run })).toBe('unknown');
|
||||
const throwing: Run = async () => {
|
||||
throw new Error('spawn EACCES');
|
||||
};
|
||||
expect(await checkDeveloperTools({ platform: 'darwin', run: throwing })).toBe('unknown');
|
||||
// An exit code that is no whole number is no answer either.
|
||||
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(Number.NaN).run })).toBe('unknown');
|
||||
});
|
||||
|
||||
it('runs nothing off macOS and answers unknown', async () => {
|
||||
for (const platform of ['linux', 'win32', 'freebsd']) {
|
||||
const { run, calls } = runner(0);
|
||||
expect(await checkDeveloperTools({ platform, run }), platform).toBe('unknown');
|
||||
expect(calls).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('spawns nothing with the default runner under vitest', async () => {
|
||||
// The real `execFile` is never reached here: on a Mac this would otherwise run the command.
|
||||
expect(await checkDeveloperTools({ platform: 'darwin' })).toBe('unknown');
|
||||
// And with no argument at all it answers for this machine without throwing.
|
||||
expect(['present', 'missing', 'unknown']).toContain(await checkDeveloperTools());
|
||||
});
|
||||
});
|
||||
|
||||
describe('the cached check', () => {
|
||||
it('reuses an answer for a minute, then asks again', async () => {
|
||||
const { run, calls } = runner(2);
|
||||
let clock = 1_000_000;
|
||||
const check = createDeveloperToolsCheck({ platform: 'darwin', run, now: () => clock });
|
||||
expect(await check()).toBe('missing');
|
||||
// A window that polls once a second for a minute.
|
||||
for (let second = 1; second < 60; second++) {
|
||||
clock += 1_000;
|
||||
expect(await check()).toBe('missing');
|
||||
}
|
||||
expect(calls).toHaveLength(1);
|
||||
clock += 1_000;
|
||||
expect(await check()).toBe('missing');
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shares one run among callers that ask at the same time', async () => {
|
||||
let release: (code: number) => void = () => undefined;
|
||||
let started = 0;
|
||||
const run: Run = () => {
|
||||
started += 1;
|
||||
return new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
};
|
||||
const check = createDeveloperToolsCheck({ platform: 'darwin', run });
|
||||
const asked = [check(), check(), check()];
|
||||
await Promise.resolve();
|
||||
expect(started).toBe(1);
|
||||
release(0);
|
||||
expect(await Promise.all(asked)).toEqual(['present', 'present', 'present']);
|
||||
});
|
||||
|
||||
it('does not remember "unknown": a check that did not work is tried again', async () => {
|
||||
let calls = 0;
|
||||
// No answer the first time, exit 0 from then on.
|
||||
const run: Run = async () => (calls++ === 0 ? null : 0);
|
||||
const check = createDeveloperToolsCheck({ platform: 'darwin', run, now: () => 5 });
|
||||
expect(await check()).toBe('unknown');
|
||||
expect(await check()).toBe('present');
|
||||
expect(await check()).toBe('present');
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it('sees the tools once they were installed, after the minute', async () => {
|
||||
const answers = [2, 0];
|
||||
let calls = 0;
|
||||
let clock = 0;
|
||||
const check = createDeveloperToolsCheck({
|
||||
platform: 'darwin',
|
||||
run: async () => answers[calls++] ?? 0,
|
||||
now: () => clock,
|
||||
});
|
||||
expect(await check()).toBe('missing');
|
||||
clock = 59_999;
|
||||
expect(await check()).toBe('missing');
|
||||
clock = 60_000;
|
||||
expect(await check()).toBe('present');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,950 @@
|
||||
/**
|
||||
* @fileoverview Tests for src/local-model/ollama-client.ts: what Codeman asks its own model
|
||||
* runtime, and the pure functions that read the answers.
|
||||
*
|
||||
* Laid out like the module:
|
||||
*
|
||||
* 1. The listings (`/api/version`, `/api/tags`, `/api/ps`, `/v1/models`, the delete), against
|
||||
* the fake runtime, and what each answers when nothing listens.
|
||||
* 2. The pull parser, pure: the two real line shapes fed in small pieces across line breaks, an
|
||||
* error line, a resumed pull, and that it keeps no line.
|
||||
* 3. `pullModel()` against the fake: the total it reports, progress, an error line and a cut
|
||||
* stream that are retried, the cap of 5 retries, a model the registry does not have (no
|
||||
* retry), a resumed pull, and Cancel.
|
||||
* 4. The check after the pull: the two request bodies, the two judges (pure), and
|
||||
* `verifyModel()` against the fake for every way it can end, the answer without a tool call
|
||||
* among them.
|
||||
*
|
||||
* Port: none fixed (the fake runtime listens on 0).
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildChatProbeRequest,
|
||||
buildToolProbeRequest,
|
||||
createPullParser,
|
||||
deleteModel,
|
||||
getRuntimeVersion,
|
||||
judgeLoaded,
|
||||
judgeToolProbe,
|
||||
listOpenAiModels,
|
||||
listRunning,
|
||||
listTags,
|
||||
PULL_RETRIES,
|
||||
pullModel,
|
||||
readModelDigest,
|
||||
TOOL_PROBE_MAX_TOKENS,
|
||||
verifyModel,
|
||||
type OllamaPsModel,
|
||||
type OllamaTarget,
|
||||
type PullProgress,
|
||||
} from '../src/local-model/ollama-client.js';
|
||||
import {
|
||||
DEFAULT_FAKE_MODEL,
|
||||
freeLoopbackPort,
|
||||
REAL_PULL_ERROR_LINE,
|
||||
startFakeOllama,
|
||||
type FakeOllama,
|
||||
type FakeOllamaOptions,
|
||||
} from './fixtures/fake-ollama.js';
|
||||
|
||||
const MODEL = DEFAULT_FAKE_MODEL.id;
|
||||
const MODEL_BYTES = DEFAULT_FAKE_MODEL.bytes;
|
||||
const CONTEXT = 131072;
|
||||
|
||||
let fake: FakeOllama | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await fake?.close();
|
||||
fake = undefined;
|
||||
});
|
||||
|
||||
/** Starts the fake and returns the target that points at it. */
|
||||
async function runtime(options: FakeOllamaOptions = {}): Promise<{ fake: FakeOllama; target: OllamaTarget }> {
|
||||
fake = await startFakeOllama(options);
|
||||
return { fake, target: { baseUrl: fake.baseUrl } };
|
||||
}
|
||||
|
||||
/** A target nothing listens on. */
|
||||
async function deadTarget(): Promise<OllamaTarget> {
|
||||
return { baseUrl: `http://127.0.0.1:${await freeLoopbackPort()}` };
|
||||
}
|
||||
|
||||
/** No waiting between retries. */
|
||||
const noSleep = async (): Promise<void> => undefined;
|
||||
|
||||
// ---------- 1. Listings ----------
|
||||
|
||||
describe('the listings', () => {
|
||||
it('reads the version of a runtime', async () => {
|
||||
const { target } = await runtime();
|
||||
expect(await getRuntimeVersion(target)).toBe('0.40.15');
|
||||
});
|
||||
|
||||
it('answers undefined for a port nothing listens on, and for a listener that is no runtime', async () => {
|
||||
expect(await getRuntimeVersion(await deadTarget())).toBeUndefined();
|
||||
// Something answers, but not with a version.
|
||||
const notARuntime: OllamaTarget = {
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
fetch: async () => new Response('<html>hello</html>', { status: 200 }),
|
||||
};
|
||||
expect(await getRuntimeVersion(notARuntime)).toBeUndefined();
|
||||
const wrongShape: OllamaTarget = {
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
fetch: async () => new Response(JSON.stringify({ version: 42 }), { status: 200 }),
|
||||
};
|
||||
expect(await getRuntimeVersion(wrongShape)).toBeUndefined();
|
||||
const refused: OllamaTarget = {
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
fetch: async () => new Response(JSON.stringify({ version: '0.40.15' }), { status: 503 }),
|
||||
};
|
||||
expect(await getRuntimeVersion(refused)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists the model on disk with what a Mac reports: size, digest, format, runner', async () => {
|
||||
const { target } = await runtime({ installed: true });
|
||||
expect(await listTags(target)).toEqual([
|
||||
{
|
||||
name: 'qwen3.6:35b-a3b',
|
||||
size: 23_105_798_956,
|
||||
digest: '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba',
|
||||
format: 'safetensors',
|
||||
runner: 'mlx',
|
||||
capabilities: ['completion', 'vision', 'tools', 'thinking'],
|
||||
},
|
||||
]);
|
||||
expect(await readModelDigest(target, MODEL)).toBe(DEFAULT_FAKE_MODEL.digest);
|
||||
expect(await readModelDigest(target, 'qwen3.5:9b')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stores a digest in one form: lowercase hex without the prefix', async () => {
|
||||
const target: OllamaTarget = {
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
fetch: async () =>
|
||||
new Response(JSON.stringify({ models: [{ name: MODEL, size: 1, digest: 'sha256:0CE5FF81' }] }), {
|
||||
status: 200,
|
||||
}),
|
||||
};
|
||||
expect(await readModelDigest(target, MODEL)).toBe('0ce5ff81');
|
||||
});
|
||||
|
||||
it('lists nothing on an empty store, and reads the "data: null" of the OpenAI route as no model', async () => {
|
||||
const { target } = await runtime();
|
||||
expect(await listTags(target)).toEqual([]);
|
||||
expect(await listRunning(target)).toEqual([]);
|
||||
expect(await listOpenAiModels(target)).toEqual([]);
|
||||
expect(await readModelDigest(target, MODEL)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists the model ids of the OpenAI route exactly as they are listed', async () => {
|
||||
const { target } = await runtime({ installed: true });
|
||||
expect(await listOpenAiModels(target)).toEqual(['qwen3.6:35b-a3b']);
|
||||
});
|
||||
|
||||
it('lists a loaded model with its memory, the part on the GPU and its context', async () => {
|
||||
const { fake: server, target } = await runtime({ installed: true });
|
||||
expect(await listRunning(target)).toEqual([]);
|
||||
server.state.loaded = true;
|
||||
expect(await listRunning(target)).toEqual([
|
||||
{
|
||||
name: 'qwen3.6:35b-a3b',
|
||||
size: 23_205_798_956,
|
||||
sizeVram: 23_205_798_956,
|
||||
contextLength: 131072,
|
||||
digest: DEFAULT_FAKE_MODEL.digest,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('answers an empty list, never an exception, when nothing listens', async () => {
|
||||
const target = await deadTarget();
|
||||
expect(await listTags(target)).toEqual([]);
|
||||
expect(await listRunning(target)).toEqual([]);
|
||||
expect(await listOpenAiModels(target)).toEqual([]);
|
||||
expect(await readModelDigest(target, MODEL)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips rows that are not models instead of failing on them', async () => {
|
||||
const target: OllamaTarget = {
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
fetch: async () =>
|
||||
new Response(JSON.stringify({ models: [null, 'x', {}, { name: 'a:b' }, { model: 'c:d', size: -1 }] }), {
|
||||
status: 200,
|
||||
}),
|
||||
};
|
||||
expect(await listTags(target)).toEqual([
|
||||
{ name: 'a:b', size: 0, digest: '' },
|
||||
{ name: 'c:d', size: 0, digest: '' },
|
||||
]);
|
||||
// A row without a digest has none to compare.
|
||||
expect(await readModelDigest(target, 'a:b')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes a model, and calls a model that never was there gone too', async () => {
|
||||
const { fake: server, target } = await runtime({ installed: true });
|
||||
expect(await deleteModel(target, MODEL)).toBe(true);
|
||||
expect(server.requests.at(-1)).toMatchObject({ method: 'DELETE', path: '/api/delete', body: { model: MODEL } });
|
||||
expect(await listTags(target)).toEqual([]);
|
||||
// The runtime answers 404 for it now.
|
||||
expect(await deleteModel(target, MODEL)).toBe(true);
|
||||
// No runtime: nothing was deleted, and it says so.
|
||||
expect(await deleteModel(await deadTarget(), MODEL)).toBe(false);
|
||||
});
|
||||
|
||||
it('gives up on a listing when the caller aborts', async () => {
|
||||
const { target } = await runtime({ installed: true });
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
expect(await listTags(target, controller.signal)).toEqual([]);
|
||||
expect(await getRuntimeVersion(target, controller.signal)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- 2. The pull parser ----------
|
||||
|
||||
describe('createPullParser', () => {
|
||||
const mlxLine = (completed: number): string =>
|
||||
JSON.stringify({ status: 'pulling model', digest: 'sha256:model', total: MODEL_BYTES, completed });
|
||||
|
||||
it('adds up the line shape of an MLX build', () => {
|
||||
const parser = createPullParser();
|
||||
parser.push('{"status":"pulling manifest"}\n');
|
||||
expect(parser.snapshot()).toEqual({
|
||||
completedBytes: 0,
|
||||
totalBytes: 0,
|
||||
status: 'pulling manifest',
|
||||
succeeded: false,
|
||||
lines: 1,
|
||||
});
|
||||
parser.push(`${mlxLine(0)}\n${mlxLine(6_200_000_000)}\n`);
|
||||
expect(parser.snapshot()).toMatchObject({ completedBytes: 6_200_000_000, totalBytes: 23_105_798_956, lines: 3 });
|
||||
parser.push(`${mlxLine(MODEL_BYTES)}\n{"status":"writing manifest"}\n{"status":"success"}\n`);
|
||||
parser.end();
|
||||
expect(parser.snapshot()).toEqual({
|
||||
completedBytes: 23_105_798_956,
|
||||
totalBytes: 23_105_798_956,
|
||||
status: 'success',
|
||||
succeeded: true,
|
||||
lines: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the same stream whatever the size of the pieces, also across line breaks', () => {
|
||||
const stream =
|
||||
['{"status":"pulling manifest"}', mlxLine(0), mlxLine(1_000), mlxLine(999_999_999), mlxLine(MODEL_BYTES)].join(
|
||||
'\n'
|
||||
) + '\n{"status":"writing manifest"}\n{"status":"success"}\n';
|
||||
const whole = createPullParser();
|
||||
whole.push(stream);
|
||||
whole.end();
|
||||
const expected = whole.snapshot();
|
||||
expect(expected).toMatchObject({ succeeded: true, completedBytes: MODEL_BYTES, lines: 7 });
|
||||
|
||||
for (const size of [1, 2, 3, 5, 7, 13, 64, 1000]) {
|
||||
const parser = createPullParser();
|
||||
for (let at = 0; at < stream.length; at += size) parser.push(stream.slice(at, at + size));
|
||||
parser.end();
|
||||
expect(parser.snapshot(), `pieces of ${size}`).toEqual(expected);
|
||||
}
|
||||
// And as bytes, one at a time: what a socket may really deliver.
|
||||
const bytes = new TextEncoder().encode(stream);
|
||||
const parser = createPullParser();
|
||||
for (const byte of bytes) parser.push(Uint8Array.of(byte));
|
||||
parser.end();
|
||||
expect(parser.snapshot()).toEqual(expected);
|
||||
});
|
||||
|
||||
it('survives a character of several bytes that is split between two pieces', () => {
|
||||
const bytes = new TextEncoder().encode('{"status":"lädt Manifest ✓"}\n{"status":"success"}\n');
|
||||
for (let cut = 1; cut < bytes.length; cut++) {
|
||||
const parser = createPullParser();
|
||||
parser.push(bytes.subarray(0, cut));
|
||||
parser.push(bytes.subarray(cut));
|
||||
parser.end();
|
||||
expect(parser.snapshot(), `cut at ${cut}`).toMatchObject({ succeeded: true, lines: 2 });
|
||||
}
|
||||
const parser = createPullParser();
|
||||
parser.push(bytes.subarray(0, 14));
|
||||
parser.push(bytes.subarray(14));
|
||||
expect(parser.snapshot().lines).toBe(2);
|
||||
});
|
||||
|
||||
it('adds up the layers of a GGUF build, each announced without "completed" first', () => {
|
||||
const parser = createPullParser();
|
||||
const big = { status: 'pulling c5396e06af29', digest: 'sha256:c5396e06af29aaaa', total: 397_807_936 };
|
||||
const small = { status: 'pulling 66b9ea09bd5b', digest: 'sha256:66b9ea09bd5bbbbb', total: 68 };
|
||||
parser.push(`${JSON.stringify(big)}\n`);
|
||||
// Known, nothing downloaded yet.
|
||||
expect(parser.snapshot()).toMatchObject({ totalBytes: 397_807_936, completedBytes: 0 });
|
||||
parser.push(`${JSON.stringify({ ...big, completed: 100_000_000 })}\n`);
|
||||
parser.push(`${JSON.stringify(small)}\n`);
|
||||
// The total grows when a new layer appears; a line without "completed" keeps what was seen.
|
||||
parser.push(`${JSON.stringify(big)}\n`);
|
||||
expect(parser.snapshot()).toMatchObject({ totalBytes: 397_808_004, completedBytes: 100_000_000 });
|
||||
parser.push(`${JSON.stringify({ ...big, completed: 397_807_936 })}\n`);
|
||||
parser.push(`${JSON.stringify({ ...small, completed: 68 })}\n`);
|
||||
parser.push('{"status":"verifying sha256 digest"}\n{"status":"writing manifest"}\n{"status":"success"}\n');
|
||||
expect(parser.snapshot()).toEqual({
|
||||
completedBytes: 397_808_004,
|
||||
totalBytes: 397_808_004,
|
||||
status: 'success',
|
||||
succeeded: true,
|
||||
lines: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a resumed pull from where it starts, not from zero', () => {
|
||||
const parser = createPullParser();
|
||||
parser.push(`{"status":"pulling manifest"}\n${mlxLine(11_000_000_000)}\n`);
|
||||
expect(parser.snapshot()).toMatchObject({ completedBytes: 11_000_000_000, totalBytes: MODEL_BYTES });
|
||||
});
|
||||
|
||||
it('keeps the text of an error line, the real one included', () => {
|
||||
const parser = createPullParser();
|
||||
parser.push(`{"status":"pulling manifest"}\n${mlxLine(5)}\n${REAL_PULL_ERROR_LINE}\n`);
|
||||
parser.end();
|
||||
expect(parser.snapshot()).toEqual({
|
||||
completedBytes: 5,
|
||||
totalBytes: MODEL_BYTES,
|
||||
status: 'pulling model',
|
||||
succeeded: false,
|
||||
error: 'max retries exceeded: local error: tls: bad record MAC',
|
||||
lines: 3,
|
||||
});
|
||||
// The first error stays.
|
||||
parser.push('{"error":"another"}\n');
|
||||
expect(parser.snapshot().error).toBe('max retries exceeded: local error: tls: bad record MAC');
|
||||
});
|
||||
|
||||
it('reads a last line that has no line break only when the body ends', () => {
|
||||
const parser = createPullParser();
|
||||
parser.push('{"status":"success"}');
|
||||
expect(parser.snapshot()).toMatchObject({ succeeded: false, lines: 0 });
|
||||
parser.end();
|
||||
expect(parser.snapshot()).toMatchObject({ succeeded: true, lines: 1 });
|
||||
});
|
||||
|
||||
it('skips a line that is not JSON and still counts it; a blank line is no line', () => {
|
||||
const parser = createPullParser();
|
||||
parser.push('not json\n\n \n[1,2]\n"text"\nnull\n{"status":"success"}\n');
|
||||
expect(parser.snapshot()).toEqual({
|
||||
completedBytes: 0,
|
||||
totalBytes: 0,
|
||||
status: 'success',
|
||||
succeeded: true,
|
||||
lines: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('never lets "completed" pass "total", and ignores sizes that are no byte counts', () => {
|
||||
const parser = createPullParser();
|
||||
parser.push('{"status":"pulling a","digest":"sha256:a","total":100,"completed":250}\n');
|
||||
parser.push('{"status":"pulling b","digest":"sha256:b","total":-5,"completed":1}\n');
|
||||
parser.push('{"status":"pulling c","digest":"sha256:c","total":"100","completed":1}\n');
|
||||
expect(parser.snapshot()).toMatchObject({ completedBytes: 100, totalBytes: 100 });
|
||||
});
|
||||
|
||||
it('keeps no line: a million progress lines leave it as small as ten', () => {
|
||||
// What it holds is one entry per layer and the unfinished line. Proven by its answers: the
|
||||
// totals are those of the LAST line of each layer, and the count is every line.
|
||||
const parser = createPullParser();
|
||||
const lines = 200_000;
|
||||
let block = '';
|
||||
for (let i = 1; i <= lines; i++) {
|
||||
block += `${mlxLine(i * 1000)}\n`;
|
||||
if (i % 5000 === 0) {
|
||||
parser.push(block);
|
||||
block = '';
|
||||
}
|
||||
}
|
||||
parser.end();
|
||||
expect(parser.snapshot()).toEqual({
|
||||
completedBytes: lines * 1000,
|
||||
totalBytes: MODEL_BYTES,
|
||||
status: 'pulling model',
|
||||
succeeded: false,
|
||||
lines,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a "line" that never ends instead of buffering it', () => {
|
||||
const parser = createPullParser();
|
||||
// Three megabytes without a line break, then the stream goes on normally.
|
||||
for (let i = 0; i < 3; i++) parser.push('x'.repeat(1024 * 1024));
|
||||
parser.push('tail of the endless line\n{"status":"success"}\n');
|
||||
expect(parser.snapshot()).toMatchObject({ succeeded: true, lines: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- 3. pullModel ----------
|
||||
|
||||
describe('pullModel', () => {
|
||||
it('pulls the model and reports the total of the pull itself', async () => {
|
||||
const { fake: server, target } = await runtime();
|
||||
const seen: PullProgress[] = [];
|
||||
const result = await pullModel(target, MODEL, { onProgress: (progress) => seen.push(progress) });
|
||||
expect(result).toEqual({ ok: true, totalBytes: 23_105_798_956 });
|
||||
expect(server.state.installed).toBe(true);
|
||||
expect(server.requests.filter((r) => r.path === '/api/pull')).toEqual([
|
||||
expect.objectContaining({ method: 'POST', body: { model: MODEL, stream: true } }),
|
||||
]);
|
||||
// The last report is the finished pull, and no report runs backwards.
|
||||
expect(seen.at(-1)).toMatchObject({ completedBytes: MODEL_BYTES, totalBytes: MODEL_BYTES });
|
||||
for (let i = 1; i < seen.length; i++) {
|
||||
expect(seen[i].completedBytes).toBeGreaterThanOrEqual(seen[i - 1].completedBytes);
|
||||
}
|
||||
for (const progress of seen) expect(progress.totalBytes).toBe(MODEL_BYTES);
|
||||
});
|
||||
|
||||
it('adds up the layers of a GGUF pull to the same total', async () => {
|
||||
const { target } = await runtime({ pull: { shape: 'gguf', progressLines: 6 } });
|
||||
expect(await pullModel(target, MODEL)).toEqual({ ok: true, totalBytes: MODEL_BYTES });
|
||||
});
|
||||
|
||||
it('reports a speed from the clock it is given', async () => {
|
||||
const { target } = await runtime({ pull: { progressLines: 8, lineDelayMs: 5 } });
|
||||
const seen: PullProgress[] = [];
|
||||
let clock = 0;
|
||||
const result = await pullModel(target, MODEL, {
|
||||
onProgress: (progress) => seen.push(progress),
|
||||
// One second passes between two looks at the clock.
|
||||
now: () => (clock += 1000),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
const speeds = seen.flatMap((progress) => (progress.bytesPerSecond === undefined ? [] : [progress.bytesPerSecond]));
|
||||
expect(speeds.length).toBeGreaterThan(0);
|
||||
for (const speed of speeds) {
|
||||
expect(Number.isInteger(speed)).toBe(true);
|
||||
expect(speed).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
// The first report has nothing to compare with.
|
||||
expect(seen[0].bytesPerSecond).toBeUndefined();
|
||||
expect(Math.max(...speeds)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('starts again after an error line, and the new pull continues where the last one stopped', async () => {
|
||||
const { fake: server, target } = await runtime({
|
||||
pull: { fail: 'error-line', failTimes: 2, failAfterLines: 10, progressLines: 20 },
|
||||
});
|
||||
const waits: number[] = [];
|
||||
const firstOfEachPull: number[] = [];
|
||||
let last = -1;
|
||||
const result = await pullModel(target, MODEL, {
|
||||
sleep: async (ms) => {
|
||||
waits.push(ms);
|
||||
last = -1;
|
||||
},
|
||||
onProgress: (progress) => {
|
||||
if (last === -1) firstOfEachPull.push(progress.completedBytes);
|
||||
last = progress.completedBytes;
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ ok: true, totalBytes: MODEL_BYTES });
|
||||
// Two failures, then the one that succeeded.
|
||||
expect(server.state.pulls).toBe(3);
|
||||
// It waits before each retry, longer every time.
|
||||
expect(waits).toEqual([1000, 2000]);
|
||||
// The second and third pull do not start at zero.
|
||||
expect(firstOfEachPull).toHaveLength(3);
|
||||
expect(firstOfEachPull[1]).toBeGreaterThan(0);
|
||||
expect(firstOfEachPull[2]).toBeGreaterThan(firstOfEachPull[1]);
|
||||
});
|
||||
|
||||
it('starts again after a stream that was cut without a word', async () => {
|
||||
const { fake: server, target } = await runtime({ pull: { fail: 'cut', failTimes: 1 } });
|
||||
expect(await pullModel(target, MODEL, { sleep: noSleep })).toEqual({ ok: true, totalBytes: MODEL_BYTES });
|
||||
expect(server.state.pulls).toBe(2);
|
||||
});
|
||||
|
||||
it('gives up after 5 retries, with the error the runtime gave', async () => {
|
||||
expect(PULL_RETRIES).toBe(5);
|
||||
const { fake: server, target } = await runtime({ pull: { fail: 'error-line' } });
|
||||
const waits: number[] = [];
|
||||
const result = await pullModel(target, MODEL, { sleep: async (ms) => void waits.push(ms) });
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'pull-failed',
|
||||
detail: 'max retries exceeded: local error: tls: bad record MAC',
|
||||
});
|
||||
// The first pull and five more.
|
||||
expect(server.state.pulls).toBe(6);
|
||||
expect(waits).toEqual([1000, 2000, 4000, 8000, 16000]);
|
||||
expect(server.state.installed).toBe(false);
|
||||
});
|
||||
|
||||
it('gives up on a stream that is cut every time, and says that it ended early', async () => {
|
||||
const { fake: server, target } = await runtime({ pull: { fail: 'cut' } });
|
||||
const result = await pullModel(target, MODEL, { sleep: noSleep, retries: 2 });
|
||||
expect(result).toMatchObject({ ok: false, code: 'pull-failed' });
|
||||
expect((result as { detail?: string }).detail).toBeTruthy();
|
||||
expect(server.state.pulls).toBe(3);
|
||||
});
|
||||
|
||||
it('does not retry when 0 retries are asked for, and falls back to 5 for a nonsense count', async () => {
|
||||
const { fake: server, target } = await runtime({ pull: { fail: 'error-line' } });
|
||||
expect(await pullModel(target, MODEL, { sleep: noSleep, retries: 0 })).toMatchObject({ code: 'pull-failed' });
|
||||
expect(server.state.pulls).toBe(1);
|
||||
await pullModel(target, MODEL, { sleep: noSleep, retries: -3 });
|
||||
expect(server.state.pulls).toBe(1 + 6);
|
||||
});
|
||||
|
||||
it('does not ask again for a model the registry does not have', async () => {
|
||||
const { fake: server, target } = await runtime({ pull: { fail: 'not-found' } });
|
||||
const result = await pullModel(target, MODEL, { sleep: noSleep });
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'model-not-found',
|
||||
detail: 'pull model manifest: file does not exist',
|
||||
});
|
||||
expect(server.state.pulls).toBe(1);
|
||||
});
|
||||
|
||||
it('does not ask again when the disk is full', async () => {
|
||||
const target: OllamaTarget = {
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
fetch: async () =>
|
||||
new Response(
|
||||
'{"status":"pulling manifest"}\n{"error":"write /models/blobs/sha256-abc-partial: no space left on device"}\n',
|
||||
{ status: 200 }
|
||||
),
|
||||
};
|
||||
let asked = 0;
|
||||
const counting: OllamaTarget = {
|
||||
...target,
|
||||
fetch: (...args) => {
|
||||
asked += 1;
|
||||
return (target.fetch as typeof fetch)(...args);
|
||||
},
|
||||
};
|
||||
expect(await pullModel(counting, MODEL, { sleep: noSleep })).toMatchObject({ ok: false, code: 'disk-full' });
|
||||
expect(asked).toBe(1);
|
||||
});
|
||||
|
||||
it('retries, then fails, when no runtime answers at all', async () => {
|
||||
const waits: number[] = [];
|
||||
const result = await pullModel(await deadTarget(), MODEL, { sleep: async (ms) => void waits.push(ms), retries: 2 });
|
||||
expect(result).toMatchObject({ ok: false, code: 'pull-failed' });
|
||||
expect(waits).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('continues a pull that an earlier run left unfinished', async () => {
|
||||
const { target } = await runtime({ pull: { resumeFrom: 11_000_000_000 } });
|
||||
const seen: number[] = [];
|
||||
const result = await pullModel(target, MODEL, { onProgress: (progress) => seen.push(progress.completedBytes) });
|
||||
expect(result).toEqual({ ok: true, totalBytes: MODEL_BYTES });
|
||||
// The first figure the window shows is where the earlier run stopped.
|
||||
expect(seen[0]).toBeGreaterThanOrEqual(11_000_000_000);
|
||||
});
|
||||
|
||||
it('reports the size of a model that was complete already, when the pull names no layer', async () => {
|
||||
// A runtime that answers "success" at once: the size is what it lists for the model.
|
||||
const { fake: server } = await runtime({ installed: true });
|
||||
const target: OllamaTarget = {
|
||||
baseUrl: server.baseUrl,
|
||||
fetch: async (input, init) =>
|
||||
String(input).endsWith('/api/pull')
|
||||
? new Response('{"status":"pulling manifest"}\n{"status":"success"}\n', { status: 200 })
|
||||
: fetch(input, init),
|
||||
};
|
||||
expect(await pullModel(target, MODEL)).toEqual({ ok: true, totalBytes: MODEL_BYTES });
|
||||
});
|
||||
|
||||
it('stops the download when the caller cancels, and does not start again', async () => {
|
||||
const { fake: server, target } = await runtime({ pull: { progressLines: 200, lineDelayMs: 10 } });
|
||||
const controller = new AbortController();
|
||||
const seen: PullProgress[] = [];
|
||||
const result = await pullModel(target, MODEL, {
|
||||
signal: controller.signal,
|
||||
sleep: noSleep,
|
||||
onProgress: (progress) => {
|
||||
seen.push(progress);
|
||||
controller.abort();
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ ok: false, code: 'cancelled' });
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(server.state.pulls).toBe(1);
|
||||
expect(server.state.installed).toBe(false);
|
||||
// Pulling again is the resume: it starts where the cancelled one stopped.
|
||||
server.switches.pull.lineDelayMs = 0;
|
||||
const again: number[] = [];
|
||||
expect(await pullModel(target, MODEL, { onProgress: (p) => again.push(p.completedBytes) })).toEqual({
|
||||
ok: true,
|
||||
totalBytes: MODEL_BYTES,
|
||||
});
|
||||
expect(again[0]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('answers cancelled at once for a signal that is already aborted', async () => {
|
||||
const { fake: server, target } = await runtime();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
expect(await pullModel(target, MODEL, { signal: controller.signal })).toEqual({ ok: false, code: 'cancelled' });
|
||||
expect(server.state.pulls).toBe(0);
|
||||
});
|
||||
|
||||
it('calls a cancel a cancel also on its last try, never a failed download', async () => {
|
||||
// With no retry left, the cut stream of an abort must not read as "the download keeps stopping".
|
||||
const { fake: server, target } = await runtime({ pull: { progressLines: 200, lineDelayMs: 10 } });
|
||||
const controller = new AbortController();
|
||||
const result = await pullModel(target, MODEL, {
|
||||
signal: controller.signal,
|
||||
retries: 0,
|
||||
onProgress: () => controller.abort(),
|
||||
});
|
||||
expect(result).toEqual({ ok: false, code: 'cancelled' });
|
||||
expect(server.state.pulls).toBe(1);
|
||||
});
|
||||
|
||||
it('does not let a progress callback that throws fail the download', async () => {
|
||||
const { fake: server, target } = await runtime();
|
||||
let called = 0;
|
||||
const result = await pullModel(target, MODEL, {
|
||||
sleep: noSleep,
|
||||
onProgress: () => {
|
||||
called += 1;
|
||||
throw new Error('the window is gone');
|
||||
},
|
||||
});
|
||||
expect(called).toBeGreaterThan(0);
|
||||
expect(result).toEqual({ ok: true, totalBytes: MODEL_BYTES });
|
||||
expect(server.state.pulls).toBe(1);
|
||||
});
|
||||
|
||||
it('answers with a result, not an exception, when its own clock throws', async () => {
|
||||
const { target } = await runtime();
|
||||
const result = await pullModel(target, MODEL, {
|
||||
sleep: noSleep,
|
||||
retries: 1,
|
||||
onProgress: () => undefined,
|
||||
now: () => {
|
||||
throw new Error('no clock');
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ ok: false, code: 'pull-failed', detail: 'no clock' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- 4. The check after the pull ----------
|
||||
|
||||
describe('the probe requests', () => {
|
||||
it('builds a tiny chat with reasoning off', () => {
|
||||
expect(buildChatProbeRequest(MODEL)).toEqual({
|
||||
model: MODEL,
|
||||
messages: [{ role: 'user', content: 'Reply with the single word: ready' }],
|
||||
reasoning_effort: 'none',
|
||||
max_tokens: 16,
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
});
|
||||
expect(buildChatProbeRequest(MODEL)).not.toHaveProperty('tools');
|
||||
});
|
||||
|
||||
it('builds a tool probe with a tools array, reasoning off and 1024 tokens', () => {
|
||||
expect(TOOL_PROBE_MAX_TOKENS).toBe(1024);
|
||||
const request = buildToolProbeRequest(MODEL) as {
|
||||
model: string;
|
||||
messages: { role: string; content: string }[];
|
||||
tools: { type: string; function: { name: string; parameters: { type: string; required: string[] } } }[];
|
||||
reasoning_effort: string;
|
||||
max_tokens: number;
|
||||
stream: boolean;
|
||||
};
|
||||
expect(request.model).toBe(MODEL);
|
||||
expect(request.reasoning_effort).toBe('none');
|
||||
expect(request.max_tokens).toBe(1024);
|
||||
expect(request.stream).toBe(false);
|
||||
expect(request.tools.map((tool) => [tool.type, tool.function.name])).toEqual([
|
||||
['function', 'read_file'],
|
||||
['function', 'write_file'],
|
||||
['function', 'run_command'],
|
||||
]);
|
||||
for (const tool of request.tools) {
|
||||
expect(tool.function.parameters.type).toBe('object');
|
||||
expect(tool.function.parameters.required.length).toBeGreaterThan(0);
|
||||
}
|
||||
// The task can only be done with a tool.
|
||||
expect(request.messages.at(-1)).toEqual({
|
||||
role: 'user',
|
||||
content: 'Read the file README.md and tell me what it says.',
|
||||
});
|
||||
// Nothing forces the call: the model has to decide to make it.
|
||||
expect(request).not.toHaveProperty('tool_choice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('judgeLoaded', () => {
|
||||
const loaded = (overrides: Partial<OllamaPsModel> = {}): OllamaPsModel => ({
|
||||
name: MODEL,
|
||||
size: 23_205_798_956,
|
||||
sizeVram: 23_205_798_956,
|
||||
contextLength: CONTEXT,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('passes a model that is all on the GPU at the chosen context', () => {
|
||||
expect(judgeLoaded([loaded()], MODEL, CONTEXT)).toEqual({
|
||||
ok: true,
|
||||
loaded: { context: 131072, sizeBytes: 23_205_798_956, sizeVramBytes: 23_205_798_956 },
|
||||
});
|
||||
// Another loaded model beside it does not matter.
|
||||
expect(judgeLoaded([loaded({ name: 'other:1', contextLength: 4096 }), loaded()], MODEL, CONTEXT).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('answers load-failed when the model is not listed', () => {
|
||||
expect(judgeLoaded([], MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'load-failed' });
|
||||
expect(judgeLoaded([loaded({ name: 'other:1' })], MODEL, CONTEXT)).toMatchObject({
|
||||
ok: false,
|
||||
code: 'load-failed',
|
||||
});
|
||||
expect(judgeLoaded([loaded({ size: 0, sizeVram: 0 })], MODEL, CONTEXT)).toMatchObject({
|
||||
ok: false,
|
||||
code: 'load-failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('answers does-not-fit when part of the model is not on the GPU', () => {
|
||||
const judged = judgeLoaded([loaded({ sizeVram: 20_000_000_000 })], MODEL, CONTEXT);
|
||||
expect(judged).toEqual({
|
||||
ok: false,
|
||||
code: 'does-not-fit',
|
||||
detail: '20000000000 of 23205798956 bytes are on the GPU',
|
||||
});
|
||||
// One byte is enough.
|
||||
expect(judgeLoaded([loaded({ sizeVram: 23_205_798_955 })], MODEL, CONTEXT)).toMatchObject({ code: 'does-not-fit' });
|
||||
expect(judgeLoaded([loaded({ sizeVram: 0 })], MODEL, CONTEXT)).toMatchObject({ code: 'does-not-fit' });
|
||||
});
|
||||
|
||||
it('answers context-not-applied when the runtime loaded another context', () => {
|
||||
expect(judgeLoaded([loaded({ contextLength: 32768 })], MODEL, CONTEXT)).toEqual({
|
||||
ok: false,
|
||||
code: 'context-not-applied',
|
||||
detail: 'The runtime reports a context of 32768 tokens, not 131072',
|
||||
});
|
||||
// A larger one is not the chosen one either.
|
||||
expect(judgeLoaded([loaded({ contextLength: 262144 })], MODEL, CONTEXT)).toMatchObject({
|
||||
code: 'context-not-applied',
|
||||
});
|
||||
});
|
||||
|
||||
it('looks at the GPU before the context', () => {
|
||||
expect(judgeLoaded([loaded({ sizeVram: 1, contextLength: 4096 })], MODEL, CONTEXT)).toMatchObject({
|
||||
code: 'does-not-fit',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('judgeToolProbe', () => {
|
||||
/** An answer of `/v1/chat/completions` with this message and finish reason. */
|
||||
const answer = (message: Record<string, unknown>, finishReason: string): unknown => ({
|
||||
id: 'chatcmpl-1',
|
||||
object: 'chat.completion',
|
||||
choices: [{ index: 0, message: { role: 'assistant', ...message }, finish_reason: finishReason }],
|
||||
});
|
||||
const call = (argumentsValue: unknown, name: unknown = 'read_file'): Record<string, unknown> => ({
|
||||
id: 'call_1',
|
||||
index: 0,
|
||||
type: 'function',
|
||||
function: { name, arguments: argumentsValue },
|
||||
});
|
||||
|
||||
it('passes on a tool call whose arguments parse as a JSON object', () => {
|
||||
expect(
|
||||
judgeToolProbe(200, answer({ content: '', tool_calls: [call('{"path":"README.md"}')] }, 'tool_calls'))
|
||||
).toEqual({ ok: true });
|
||||
// Arguments that are an object already, and an empty object, are arguments too.
|
||||
expect(judgeToolProbe(200, answer({ tool_calls: [call({ path: 'README.md' })] }, 'tool_calls'))).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
expect(judgeToolProbe(200, answer({ tool_calls: [call('{}')] }, 'tool_calls'))).toEqual({ ok: true });
|
||||
// The finish reason is not what decides: the call is.
|
||||
expect(judgeToolProbe(200, answer({ tool_calls: [call('{"path":"a"}')] }, 'stop'))).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('answers no-tool-call for an answer in plain text', () => {
|
||||
expect(judgeToolProbe(200, answer({ content: 'I would read the file, but I cannot.' }, 'stop'))).toEqual({
|
||||
ok: false,
|
||||
code: 'no-tool-call',
|
||||
detail: 'The model answered in text: I would read the file, but I cannot.',
|
||||
});
|
||||
expect(judgeToolProbe(200, answer({ content: '', tool_calls: [] }, 'stop'))).toMatchObject({
|
||||
ok: false,
|
||||
code: 'no-tool-call',
|
||||
});
|
||||
});
|
||||
|
||||
it('answers no-tool-call for a tool call whose arguments are not a JSON object', () => {
|
||||
for (const bad of ['{"path": ', 'README.md', '"README.md"', '[1]', 'null', '', 42, null, undefined]) {
|
||||
expect(judgeToolProbe(200, answer({ tool_calls: [call(bad)] }, 'tool_calls')), JSON.stringify(bad)).toMatchObject(
|
||||
{
|
||||
ok: false,
|
||||
code: 'no-tool-call',
|
||||
}
|
||||
);
|
||||
}
|
||||
// A call without a name, and one good call beside a broken one.
|
||||
expect(judgeToolProbe(200, answer({ tool_calls: [call('{}', '')] }, 'tool_calls'))).toMatchObject({
|
||||
code: 'no-tool-call',
|
||||
});
|
||||
expect(
|
||||
judgeToolProbe(200, answer({ tool_calls: [call('{"path":"a"}'), call('{"path": ')] }, 'tool_calls'))
|
||||
).toMatchObject({ code: 'no-tool-call' });
|
||||
expect(judgeToolProbe(200, answer({ tool_calls: ['read_file'] }, 'tool_calls'))).toMatchObject({
|
||||
code: 'no-tool-call',
|
||||
});
|
||||
});
|
||||
|
||||
it('answers probe-unfinished when the token limit ended the answer: that proves nothing', () => {
|
||||
expect(
|
||||
judgeToolProbe(200, answer({ content: '', reasoning: 'The user wants me to read a file. I should' }, 'length'))
|
||||
).toEqual({ ok: false, code: 'probe-unfinished', detail: 'The answer ended at 1024 tokens' });
|
||||
});
|
||||
|
||||
it('answers load-failed for any other status, with what the runtime said', () => {
|
||||
expect(judgeToolProbe(500, { error: { message: 'llm server error: out of memory' } })).toEqual({
|
||||
ok: false,
|
||||
code: 'load-failed',
|
||||
detail: 'llm server error: out of memory',
|
||||
});
|
||||
expect(judgeToolProbe(404, { error: "model 'x' not found" })).toMatchObject({
|
||||
code: 'load-failed',
|
||||
detail: "model 'x' not found",
|
||||
});
|
||||
expect(judgeToolProbe(502, undefined)).toEqual({
|
||||
ok: false,
|
||||
code: 'load-failed',
|
||||
detail: 'The runtime answered 502',
|
||||
});
|
||||
});
|
||||
|
||||
it('answers no-tool-call for a 200 it cannot read at all', () => {
|
||||
for (const body of [undefined, null, 'text', [], {}, { choices: [] }, { choices: [null] }, { choices: [{}] }]) {
|
||||
expect(judgeToolProbe(200, body), JSON.stringify(body)).toEqual({
|
||||
ok: false,
|
||||
code: 'no-tool-call',
|
||||
detail: 'The answer holds no tool call',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyModel', () => {
|
||||
it('passes a model that loads, sits on the GPU at the chosen context and calls a tool', async () => {
|
||||
const { fake: server, target } = await runtime({ installed: true });
|
||||
expect(await verifyModel(target, MODEL, CONTEXT)).toEqual({
|
||||
ok: true,
|
||||
loaded: { context: 131072, sizeBytes: 23_205_798_956, sizeVramBytes: 23_205_798_956 },
|
||||
});
|
||||
// In this order: the chat that loads it, the process list, the tool probe.
|
||||
expect(server.requests.map((r) => `${r.method} ${r.path}`)).toEqual([
|
||||
'POST /v1/chat/completions',
|
||||
'GET /api/ps',
|
||||
'POST /v1/chat/completions',
|
||||
]);
|
||||
const [chat, , probe] = server.requests.map((r) => r.body as Record<string, unknown>);
|
||||
expect(chat).toEqual(buildChatProbeRequest(MODEL));
|
||||
expect(chat).toMatchObject({ reasoning_effort: 'none' });
|
||||
expect(chat).not.toHaveProperty('tools');
|
||||
expect(probe).toEqual(buildToolProbeRequest(MODEL));
|
||||
expect(probe).toMatchObject({ reasoning_effort: 'none', max_tokens: 1024 });
|
||||
expect(Array.isArray(probe.tools)).toBe(true);
|
||||
});
|
||||
|
||||
it('answers load-failed when the chat that loads the model fails, and asks nothing more', async () => {
|
||||
const { fake: server, target } = await runtime({ installed: true, chat: 'error' });
|
||||
expect(await verifyModel(target, MODEL, CONTEXT)).toEqual({
|
||||
ok: false,
|
||||
code: 'load-failed',
|
||||
detail: 'llm server error: out of memory',
|
||||
});
|
||||
expect(server.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('answers load-failed for a model that is not on disk', async () => {
|
||||
const { target } = await runtime();
|
||||
expect(await verifyModel(target, MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'load-failed' });
|
||||
});
|
||||
|
||||
it('answers load-failed when no runtime answers', async () => {
|
||||
expect(await verifyModel(await deadTarget(), MODEL, CONTEXT)).toEqual({
|
||||
ok: false,
|
||||
code: 'load-failed',
|
||||
detail: 'The runtime did not answer the first request',
|
||||
});
|
||||
});
|
||||
|
||||
it('answers load-failed when the model is not in the process list after the chat', async () => {
|
||||
const { fake: server, target } = await runtime({ installed: true, ps: { loaded: false } });
|
||||
expect(await verifyModel(target, MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'load-failed' });
|
||||
// The tool probe was not sent.
|
||||
expect(server.requests.filter((r) => r.path === '/v1/chat/completions')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('answers does-not-fit when part of the model is not on the GPU, before any tool probe', async () => {
|
||||
const { fake: server, target } = await runtime({ installed: true, ps: { sizeVram: 18_000_000_000 } });
|
||||
expect(await verifyModel(target, MODEL, CONTEXT)).toEqual({
|
||||
ok: false,
|
||||
code: 'does-not-fit',
|
||||
detail: '18000000000 of 23205798956 bytes are on the GPU',
|
||||
});
|
||||
expect(server.requests.filter((r) => r.path === '/v1/chat/completions')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('answers context-not-applied when the runtime loaded another context than the chosen one', async () => {
|
||||
const { target } = await runtime({ installed: true, contextLength: 32768 });
|
||||
expect(await verifyModel(target, MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'context-not-applied' });
|
||||
// The same runtime passes when 32768 is what was chosen.
|
||||
expect(await verifyModel(target, MODEL, 32768)).toMatchObject({ ok: true, loaded: { context: 32768 } });
|
||||
});
|
||||
|
||||
it('answers no-tool-call when the model answers the tool probe in text', async () => {
|
||||
const { target } = await runtime({ installed: true, tool: 'none' });
|
||||
expect(await verifyModel(target, MODEL, CONTEXT)).toEqual({
|
||||
ok: false,
|
||||
code: 'no-tool-call',
|
||||
detail: 'The model answered in text: I would read the file, but I cannot.',
|
||||
});
|
||||
});
|
||||
|
||||
it('answers no-tool-call when the arguments of the tool call do not parse', async () => {
|
||||
const { target } = await runtime({ installed: true, tool: 'bad-arguments' });
|
||||
expect(await verifyModel(target, MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'no-tool-call' });
|
||||
});
|
||||
|
||||
it('answers probe-unfinished, not no-tool-call, when the token limit ended the answer', async () => {
|
||||
const { target } = await runtime({ installed: true, tool: 'length' });
|
||||
expect(await verifyModel(target, MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'probe-unfinished' });
|
||||
});
|
||||
|
||||
it('answers cancelled for an aborted signal, at whatever point it is noticed', async () => {
|
||||
const { fake: server, target } = await runtime({ installed: true });
|
||||
const before = new AbortController();
|
||||
before.abort();
|
||||
expect(await verifyModel(target, MODEL, CONTEXT, before.signal)).toEqual({ ok: false, code: 'cancelled' });
|
||||
expect(server.requests).toHaveLength(0);
|
||||
|
||||
// Aborted while the first request is on its way.
|
||||
const during = new AbortController();
|
||||
const slow: OllamaTarget = {
|
||||
baseUrl: server.baseUrl,
|
||||
fetch: async (input, init) => {
|
||||
during.abort();
|
||||
return fetch(input, init);
|
||||
},
|
||||
};
|
||||
expect(await verifyModel(slow, MODEL, CONTEXT, during.signal)).toEqual({ ok: false, code: 'cancelled' });
|
||||
});
|
||||
|
||||
it('never throws, whatever the runtime sends back', async () => {
|
||||
const answers = ['', 'not json', '[]', 'null', '{"choices":"x"}'];
|
||||
for (const text of answers) {
|
||||
const target: OllamaTarget = {
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
fetch: async () => new Response(text, { status: 200 }),
|
||||
};
|
||||
const result = await verifyModel(target, MODEL, CONTEXT);
|
||||
// The chat was "answered", the process list lists nothing.
|
||||
expect(result, text).toMatchObject({ ok: false, code: 'load-failed' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,982 @@
|
||||
/**
|
||||
* @fileoverview Tests for src/local-model/runtime.ts, everything but the supervisor (that is
|
||||
* test/local-model-supervisor.test.ts): the pin, the budget line, the child's environment,
|
||||
* the download with its hash, the port, and the removal.
|
||||
*
|
||||
* Pinned:
|
||||
* - The pin is the reviewed constant, and a test never reaches it: the default `fetch`
|
||||
* refuses under vitest.
|
||||
* - The budget parser reads the REAL log line of both test Macs and no other line.
|
||||
* - The environment is an allowlist: no `CODEMAN_PASSWORD`, none of the user's own
|
||||
* `OLLAMA_*`, nothing that is not named.
|
||||
* - The download: a matching hash unpacks, a wrong hash deletes the file and unpacks
|
||||
* NOTHING, a cut body is continued with `Range`, the retries are capped, a body longer
|
||||
* than the pin is refused, Cancel keeps the partial file for the next run.
|
||||
* - The port is chosen by binding.
|
||||
* - The removal deletes only inside the one folder and follows no symlink out of it.
|
||||
*
|
||||
* Port: none fixed (the fake runtime listens on 0; the port tests bind 0 and read the port).
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises';
|
||||
import { createServer, type AddressInfo, type Server } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { localModelPaths, type LocalModelPaths } from '../src/local-model/paths.js';
|
||||
import {
|
||||
chooseRuntimePort,
|
||||
ensureRuntimeInstalled,
|
||||
isLoopbackPortFree,
|
||||
isOwnRuntimeCommand,
|
||||
isRuntimeInstalled,
|
||||
OLLAMA_RUNTIME_PIN,
|
||||
parseInferenceComputeTotal,
|
||||
removeLocalModelFiles,
|
||||
RUNTIME_DOWNLOAD_RETRIES,
|
||||
RUNTIME_KEEP_ALIVE,
|
||||
RUNTIME_MAX_RESTARTS,
|
||||
RUNTIME_PORT_FIRST,
|
||||
RUNTIME_PORT_SPAN,
|
||||
runtimeEnv,
|
||||
type RuntimeDownloadProgress,
|
||||
} from '../src/local-model/runtime.js';
|
||||
import type { LocalModelRuntimePin } from '../src/types.js';
|
||||
import {
|
||||
buildFakeArchive,
|
||||
fakeRuntimeLog,
|
||||
INFERENCE_COMPUTE_LINE_MACBOOK,
|
||||
MACBOOK_MEASURED_TOTAL_BYTES,
|
||||
REAL_INFERENCE_COMPUTE_LINE_MAC_MINI,
|
||||
REAL_VRAM_DEFAULT_CONTEXT_LINE_MAC_MINI,
|
||||
startFakeOllama,
|
||||
type FakeOllama,
|
||||
} from './fixtures/fake-ollama.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
let fake: FakeOllama | undefined;
|
||||
let paths: LocalModelPaths;
|
||||
const cleanup: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
// Under the temp HOME test/setup.ts gives this file.
|
||||
paths = localModelPaths('0.40.15');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fake?.close();
|
||||
fake = undefined;
|
||||
await rm(paths.root, { recursive: true, force: true });
|
||||
for (const dir of cleanup.splice(0)) await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A temp folder outside the local model folder, removed after the test. */
|
||||
async function outsideDir(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'local-model-outside-'));
|
||||
cleanup.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** What `ensureRuntimeInstalled()` needs to reach the fake: the global `fetch`, and no waiting. */
|
||||
function deps(waits: number[] = []): { fetch: typeof fetch; sleep: (ms: number) => Promise<void> } {
|
||||
return {
|
||||
fetch,
|
||||
sleep: async (ms) => {
|
||||
waits.push(ms);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The archive requests the fake saw, as the `Range` header of each (`undefined` without one). */
|
||||
function archiveRanges(server: FakeOllama): (string | undefined)[] {
|
||||
return server.requests
|
||||
.filter((request) => request.path === '/download/ollama-darwin.tgz')
|
||||
.map((request) => request.headers.range as string | undefined);
|
||||
}
|
||||
|
||||
/** A fake whose archive is about 40 KB: a runtime script plus bytes that do not compress. */
|
||||
async function fakeWithLargeArchive(archive: FakeOllama['switches']['archive'] | object = {}): Promise<FakeOllama> {
|
||||
fake = await startFakeOllama({
|
||||
archive,
|
||||
archiveFiles: { ollama: '#!/bin/sh\necho "fake runtime"\n', 'lib/blob.bin': randomBytes(30_000).toString('hex') },
|
||||
});
|
||||
return fake;
|
||||
}
|
||||
|
||||
// ---------- The pin and the constants ----------
|
||||
|
||||
describe('the pin', () => {
|
||||
it('is the reviewed build: version, address, size and hash', () => {
|
||||
expect(OLLAMA_RUNTIME_PIN).toEqual({
|
||||
id: 'ollama',
|
||||
version: '0.40.15',
|
||||
url: 'https://github.com/ollama/ollama/releases/download/v0.40.15/ollama-darwin.tgz',
|
||||
bytes: 185_971_671,
|
||||
sha256: '607c79d5d6315ef9d896db83adde736a4c0057196329843544e79b95afb260a8',
|
||||
unpackedBytes: 579_000_000,
|
||||
minMacosMajor: 14,
|
||||
});
|
||||
});
|
||||
|
||||
it('cannot be changed by code that holds it', () => {
|
||||
expect(Object.isFrozen(OLLAMA_RUNTIME_PIN)).toBe(true);
|
||||
expect(() => {
|
||||
(OLLAMA_RUNTIME_PIN as { url: string }).url = 'https://example.com/other.tgz';
|
||||
}).toThrow();
|
||||
expect(OLLAMA_RUNTIME_PIN.url).toMatch(/^https:\/\/github\.com\/ollama\/ollama\/releases\/download\/v0\.40\.15\//);
|
||||
});
|
||||
|
||||
it('states the limits of the supervisor and the download', () => {
|
||||
expect(RUNTIME_PORT_FIRST).toBe(11435);
|
||||
expect(RUNTIME_PORT_SPAN).toBe(20);
|
||||
expect(RUNTIME_MAX_RESTARTS).toBe(3);
|
||||
expect(RUNTIME_DOWNLOAD_RETRIES).toBe(3);
|
||||
expect(RUNTIME_KEEP_ALIVE).toBe('10m');
|
||||
});
|
||||
|
||||
it('is never downloaded by a test: the default fetch refuses under vitest', async () => {
|
||||
const waits: number[] = [];
|
||||
const result = await ensureRuntimeInstalled({ deps: { sleep: async (ms) => void waits.push(ms) } });
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'runtime-download-failed',
|
||||
detail: 'fetch is not available under vitest: the test must inject its own',
|
||||
});
|
||||
// Refused at once, not after the retries.
|
||||
expect(waits).toEqual([]);
|
||||
expect(await isRuntimeInstalled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- The budget line ----------
|
||||
|
||||
describe('parseInferenceComputeTotal', () => {
|
||||
it('reads the device total of the real line of the Mac mini', () => {
|
||||
// total="11.8 GiB": 11.8 x 1024^3, rounded down.
|
||||
expect(parseInferenceComputeTotal(REAL_INFERENCE_COMPUTE_LINE_MAC_MINI)).toBe(12_670_153_523);
|
||||
expect(12_670_153_523).toBe(Math.floor(11.8 * 1024 ** 3));
|
||||
});
|
||||
|
||||
it('reads 37.4 GiB, what the MacBook reported, as 40,157,944,217 bytes', () => {
|
||||
expect(parseInferenceComputeTotal(INFERENCE_COMPUTE_LINE_MACBOOK)).toBe(40_157_944_217);
|
||||
expect(parseInferenceComputeTotal(INFERENCE_COMPUTE_LINE_MACBOOK)).toBe(MACBOOK_MEASURED_TOTAL_BYTES);
|
||||
// The two fields quoted in the measurement notes are enough.
|
||||
expect(parseInferenceComputeTotal('msg="inference compute" total="37.4 GiB" available="37.4 GiB"')).toBe(
|
||||
40_157_944_217
|
||||
);
|
||||
});
|
||||
|
||||
it('does not read the line that follows it, which carries the total under another key', () => {
|
||||
expect(parseInferenceComputeTotal(REAL_VRAM_DEFAULT_CONTEXT_LINE_MAC_MINI)).toBeUndefined();
|
||||
// Not even when that key sits on an "inference compute" line.
|
||||
expect(
|
||||
parseInferenceComputeTotal('msg="inference compute" total_vram="11.8 GiB" subtotal="1 GiB"')
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('finds exactly one line of a start-up log', () => {
|
||||
const totals = fakeRuntimeLog('37.4 GiB').map(parseInferenceComputeTotal);
|
||||
expect(totals).toEqual([undefined, undefined, 40_157_944_217, undefined]);
|
||||
});
|
||||
|
||||
it('reads the binary units the runtime prints', () => {
|
||||
const line = (total: string): string => `level=INFO msg="inference compute" id=0 total="${total}" available="1 B"`;
|
||||
expect(parseInferenceComputeTotal(line('512 B'))).toBe(512);
|
||||
expect(parseInferenceComputeTotal(line('8 KiB'))).toBe(8192);
|
||||
expect(parseInferenceComputeTotal(line('12713.1 MiB'))).toBe(Math.floor(12713.1 * 1024 ** 2));
|
||||
expect(parseInferenceComputeTotal(line('96 GiB'))).toBe(96 * 1024 ** 3);
|
||||
expect(parseInferenceComputeTotal(line('1.5 TiB'))).toBe(1.5 * 1024 ** 4);
|
||||
// Without the space between the number and the unit.
|
||||
expect(parseInferenceComputeTotal(line('16GiB'))).toBe(16 * 1024 ** 3);
|
||||
});
|
||||
|
||||
it('answers undefined for anything it does not understand, and never throws', () => {
|
||||
const line = (total: string): string => `msg="inference compute" total="${total}"`;
|
||||
for (const text of [
|
||||
'',
|
||||
'total="37.4 GiB"',
|
||||
'msg="inference compute"',
|
||||
line(''),
|
||||
line('GiB'),
|
||||
line('37.4'),
|
||||
line('37.4 GB'),
|
||||
line('-1 GiB'),
|
||||
line('0 GiB'),
|
||||
line('1e9 GiB'),
|
||||
line('NaN GiB'),
|
||||
'msg="inference compute" total=37.4GiB',
|
||||
'msg="discovering available GPUs..."',
|
||||
]) {
|
||||
expect(parseInferenceComputeTotal(text), JSON.stringify(text)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- The environment ----------
|
||||
|
||||
describe('runtimeEnv', () => {
|
||||
const input = (source: NodeJS.ProcessEnv): Parameters<typeof runtimeEnv>[0] => ({
|
||||
source,
|
||||
home: '/data/local-model/home',
|
||||
modelsDir: '/data/local-model/models',
|
||||
port: 11435,
|
||||
contextLength: 131072,
|
||||
});
|
||||
|
||||
/** A server environment with everything a real one may hold. */
|
||||
const SERVER_ENV: NodeJS.ProcessEnv = {
|
||||
PATH: '/usr/local/bin:/usr/bin:/bin',
|
||||
HOME: '/Users/arkon',
|
||||
TMPDIR: '/var/folders/xy/T/',
|
||||
LANG: 'de_CH.UTF-8',
|
||||
HTTP_PROXY: 'http://proxy:3128',
|
||||
HTTPS_PROXY: 'http://proxy:3128',
|
||||
NO_PROXY: 'localhost,127.0.0.1',
|
||||
http_proxy: 'http://proxy-lower:3128',
|
||||
https_proxy: 'http://proxy-lower:3128',
|
||||
no_proxy: 'localhost',
|
||||
// Codeman's own secrets and switches.
|
||||
CODEMAN_PASSWORD: 'hunter2',
|
||||
CODEMAN_USERNAME: 'admin',
|
||||
CODEMAN_MUX: '1',
|
||||
CODEMAN_API_URL: 'http://127.0.0.1:3000',
|
||||
// The user's settings for their OWN runtime.
|
||||
OLLAMA_HOST: '0.0.0.0:11434',
|
||||
OLLAMA_ORIGINS: '*',
|
||||
OLLAMA_MODELS: '/Users/arkon/.ollama/models',
|
||||
OLLAMA_DEBUG: '1',
|
||||
OLLAMA_DEBUG_LOG_REQUESTS: '1',
|
||||
OLLAMA_KEEP_ALIVE: '-1',
|
||||
OLLAMA_NUM_PARALLEL: '8',
|
||||
OLLAMA_MAX_LOADED_MODELS: '0',
|
||||
OLLAMA_CONTEXT_LENGTH: '4096',
|
||||
OLLAMA_NO_CLOUD: '0',
|
||||
OLLAMA_REMOTES: 'evil.example',
|
||||
OLLAMA_AUTH: '1',
|
||||
LLAMA_ARG_CACHE_RAM: '0',
|
||||
// Other secrets a server environment carries.
|
||||
ANTHROPIC_API_KEY: 'sk-ant-secret',
|
||||
OPENAI_API_KEY: 'sk-secret',
|
||||
GITHUB_TOKEN: 'ghp_secret',
|
||||
AWS_SECRET_ACCESS_KEY: 'aws-secret',
|
||||
SSH_AUTH_SOCK: '/tmp/ssh.sock',
|
||||
USER: 'arkon',
|
||||
SHELL: '/bin/zsh',
|
||||
LC_ALL: 'C',
|
||||
NODE_OPTIONS: '--inspect',
|
||||
DYLD_INSERT_LIBRARIES: '/tmp/evil.dylib',
|
||||
};
|
||||
|
||||
it('holds exactly the allowlist: what is copied, the private HOME, and seven settings', () => {
|
||||
expect(runtimeEnv(input(SERVER_ENV))).toEqual({
|
||||
PATH: '/usr/local/bin:/usr/bin:/bin',
|
||||
TMPDIR: '/var/folders/xy/T/',
|
||||
LANG: 'de_CH.UTF-8',
|
||||
HTTP_PROXY: 'http://proxy:3128',
|
||||
HTTPS_PROXY: 'http://proxy:3128',
|
||||
NO_PROXY: 'localhost,127.0.0.1',
|
||||
http_proxy: 'http://proxy-lower:3128',
|
||||
https_proxy: 'http://proxy-lower:3128',
|
||||
no_proxy: 'localhost',
|
||||
HOME: '/data/local-model/home',
|
||||
OLLAMA_HOST: '127.0.0.1:11435',
|
||||
OLLAMA_MODELS: '/data/local-model/models',
|
||||
OLLAMA_CONTEXT_LENGTH: '131072',
|
||||
OLLAMA_NO_CLOUD: '1',
|
||||
OLLAMA_KEEP_ALIVE: '10m',
|
||||
OLLAMA_NUM_PARALLEL: '1',
|
||||
OLLAMA_MAX_LOADED_MODELS: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('lets no CODEMAN_ variable through, the password least of all', () => {
|
||||
const env = runtimeEnv(input(SERVER_ENV));
|
||||
expect(Object.keys(env).filter((name) => name.startsWith('CODEMAN_'))).toEqual([]);
|
||||
expect(JSON.stringify(env)).not.toContain('hunter2');
|
||||
});
|
||||
|
||||
it("lets none of the user's own OLLAMA_ settings through: every one is ours or absent", () => {
|
||||
const env = runtimeEnv(input(SERVER_ENV));
|
||||
expect(
|
||||
Object.keys(env)
|
||||
.filter((name) => name.startsWith('OLLAMA_'))
|
||||
.sort()
|
||||
).toEqual([
|
||||
'OLLAMA_CONTEXT_LENGTH',
|
||||
'OLLAMA_HOST',
|
||||
'OLLAMA_KEEP_ALIVE',
|
||||
'OLLAMA_MAX_LOADED_MODELS',
|
||||
'OLLAMA_MODELS',
|
||||
'OLLAMA_NO_CLOUD',
|
||||
'OLLAMA_NUM_PARALLEL',
|
||||
]);
|
||||
// The inherited values lost against ours, and the ones we do not set are gone.
|
||||
expect(env.OLLAMA_HOST).toBe('127.0.0.1:11435');
|
||||
expect(env.OLLAMA_KEEP_ALIVE).toBe('10m');
|
||||
expect(env.OLLAMA_MAX_LOADED_MODELS).toBe('1');
|
||||
for (const name of [
|
||||
'OLLAMA_ORIGINS',
|
||||
'OLLAMA_DEBUG',
|
||||
'OLLAMA_DEBUG_LOG_REQUESTS',
|
||||
'OLLAMA_REMOTES',
|
||||
'OLLAMA_AUTH',
|
||||
]) {
|
||||
expect(env, name).not.toHaveProperty(name);
|
||||
}
|
||||
expect(env).not.toHaveProperty('LLAMA_ARG_CACHE_RAM');
|
||||
});
|
||||
|
||||
it('carries no secret and no value of the server that is not on the list', () => {
|
||||
const env = runtimeEnv(input(SERVER_ENV));
|
||||
const text = JSON.stringify(env);
|
||||
for (const secret of ['sk-ant-secret', 'sk-secret', 'ghp_secret', 'aws-secret', 'evil', '/Users/arkon']) {
|
||||
expect(text, secret).not.toContain(secret);
|
||||
}
|
||||
// Whatever is in the result and came from the server is one of the nine copied names.
|
||||
const copied = [
|
||||
'PATH',
|
||||
'TMPDIR',
|
||||
'LANG',
|
||||
'HTTP_PROXY',
|
||||
'HTTPS_PROXY',
|
||||
'NO_PROXY',
|
||||
'http_proxy',
|
||||
'https_proxy',
|
||||
'no_proxy',
|
||||
];
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
if (SERVER_ENV[name] === value) expect(copied, name).toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it('binds the loopback address only, on the port it is given', () => {
|
||||
for (const port of [11435, 11454, 40123]) {
|
||||
expect(runtimeEnv({ ...input({}), port }).OLLAMA_HOST).toBe(`127.0.0.1:${port}`);
|
||||
}
|
||||
expect(runtimeEnv({ ...input({}), contextLength: 65536 }).OLLAMA_CONTEXT_LENGTH).toBe('65536');
|
||||
});
|
||||
|
||||
it('leaves out what the server does not have, and sets the rest all the same', () => {
|
||||
expect(Object.keys(runtimeEnv(input({}))).sort()).toEqual([
|
||||
'HOME',
|
||||
'OLLAMA_CONTEXT_LENGTH',
|
||||
'OLLAMA_HOST',
|
||||
'OLLAMA_KEEP_ALIVE',
|
||||
'OLLAMA_MAX_LOADED_MODELS',
|
||||
'OLLAMA_MODELS',
|
||||
'OLLAMA_NO_CLOUD',
|
||||
'OLLAMA_NUM_PARALLEL',
|
||||
]);
|
||||
// An empty value is not copied either.
|
||||
expect(runtimeEnv(input({ PATH: '', LANG: undefined }))).not.toHaveProperty('PATH');
|
||||
});
|
||||
|
||||
it('is pure: the server environment is not changed', () => {
|
||||
const source = { ...SERVER_ENV };
|
||||
runtimeEnv(input(source));
|
||||
expect(source).toEqual(SERVER_ENV);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Whose process is it ----------
|
||||
|
||||
describe('isOwnRuntimeCommand', () => {
|
||||
const root = '/Users/arkon/.codeman/local-model/runtime';
|
||||
|
||||
it('knows a runtime under our folder by its command line', () => {
|
||||
expect(isOwnRuntimeCommand(`${root}/ollama-0.40.15/ollama serve`, root)).toBe(true);
|
||||
expect(isOwnRuntimeCommand(` ${root}/ollama-0.40.15/ollama serve\n`, root)).toBe(true);
|
||||
// Another version of ours, and a folder name with a space in it.
|
||||
expect(isOwnRuntimeCommand(`${root}/ollama-0.39.0/ollama serve`, root)).toBe(true);
|
||||
const spaced = '/Users/Jane Doe/.codeman/local-model/runtime';
|
||||
expect(isOwnRuntimeCommand(`${spaced}/ollama-0.40.15/ollama serve`, spaced)).toBe(true);
|
||||
expect(isOwnRuntimeCommand(`${root}/ollama-0.40.15/ollama serve`, `${root}/`)).toBe(true);
|
||||
});
|
||||
|
||||
it('reads /private in front of a path as the same path', () => {
|
||||
const temp = '/var/folders/xy/T/codeman/local-model/runtime';
|
||||
expect(isOwnRuntimeCommand(`/private${temp}/ollama-0.40.15/ollama serve`, temp)).toBe(true);
|
||||
expect(isOwnRuntimeCommand(`${temp}/ollama-0.40.15/ollama serve`, `/private${temp}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not take any other process for ours', () => {
|
||||
for (const command of [
|
||||
// A runtime the user started.
|
||||
'/usr/local/bin/ollama serve',
|
||||
'/Applications/Ollama.app/Contents/Resources/ollama serve',
|
||||
'ollama serve',
|
||||
// Our folder is only an argument, or a prefix of another folder.
|
||||
`/bin/sh ${root}/ollama-0.40.15/ollama serve`,
|
||||
`/usr/bin/tail -f ${root}/log`,
|
||||
`${root}-other/ollama-0.40.15/ollama serve`,
|
||||
`${root}`,
|
||||
// A way back out of the folder.
|
||||
`${root}/../../../../usr/local/bin/ollama serve`,
|
||||
`${root}/ollama-0.40.15/../../../evil serve`,
|
||||
'',
|
||||
]) {
|
||||
expect(isOwnRuntimeCommand(command, root), command).toBe(false);
|
||||
}
|
||||
expect(isOwnRuntimeCommand('/anything/ollama serve', '')).toBe(false);
|
||||
expect(isOwnRuntimeCommand('relative/ollama serve', 'relative')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- The download ----------
|
||||
|
||||
describe('ensureRuntimeInstalled', () => {
|
||||
it('downloads, proves the hash, unpacks, and leaves a runtime that runs', async () => {
|
||||
fake = await startFakeOllama();
|
||||
const seen: RuntimeDownloadProgress[] = [];
|
||||
const result = await ensureRuntimeInstalled({
|
||||
pin: fake.pin(),
|
||||
paths,
|
||||
deps: deps(),
|
||||
onProgress: (progress) => seen.push(progress),
|
||||
});
|
||||
expect(result).toEqual({ ok: true, binaryPath: paths.binary, alreadyInstalled: false });
|
||||
expect(await isRuntimeInstalled(fake.pin(), paths)).toBe(true);
|
||||
// Unpacked by the real tar: the file is executable and is the one of the archive.
|
||||
expect((await stat(paths.binary)).mode & 0o111).not.toBe(0);
|
||||
expect((await execFileAsync(paths.binary)).stdout).toBe('fake runtime\n');
|
||||
// The archive and the temporary folder are gone; one request was enough.
|
||||
expect(existsSync(paths.archivePartial)).toBe(false);
|
||||
expect(existsSync(paths.unpackDir)).toBe(false);
|
||||
expect(archiveRanges(fake)).toEqual([undefined]);
|
||||
// Progress ends at the whole archive, measured against the pin.
|
||||
expect(seen.at(-1)).toEqual({ completedBytes: fake.archive.size, totalBytes: fake.archive.size });
|
||||
for (const progress of seen) expect(progress.totalBytes).toBe(fake.pin().bytes);
|
||||
});
|
||||
|
||||
it('does nothing the second time: the runtime is already there', async () => {
|
||||
fake = await startFakeOllama();
|
||||
await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() });
|
||||
const again = await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() });
|
||||
expect(again).toEqual({ ok: true, binaryPath: paths.binary, alreadyInstalled: true });
|
||||
expect(fake.state.archiveRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('unpacks NOTHING when the hash is wrong, and deletes the download', async () => {
|
||||
fake = await startFakeOllama();
|
||||
const wrongPin: LocalModelRuntimePin = {
|
||||
...fake.pin(),
|
||||
sha256: createHash('sha256').update('other').digest('hex'),
|
||||
};
|
||||
let unpacked = 0;
|
||||
const result = await ensureRuntimeInstalled({
|
||||
pin: wrongPin,
|
||||
paths,
|
||||
deps: {
|
||||
...deps(),
|
||||
untar: async () => {
|
||||
unpacked += 1;
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, code: 'runtime-damaged' });
|
||||
expect((result as { detail?: string }).detail).toContain(fake.archive.sha256);
|
||||
expect(unpacked).toBe(0);
|
||||
expect(existsSync(paths.archivePartial)).toBe(false);
|
||||
expect(existsSync(paths.unpackDir)).toBe(false);
|
||||
expect(existsSync(paths.runtimeDir)).toBe(false);
|
||||
expect(await isRuntimeInstalled(wrongPin, paths)).toBe(false);
|
||||
// The whole archive was read before the verdict: it is the hash that failed, not the size.
|
||||
expect(fake.state.archiveRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('compares the hash whatever the case it is written in', async () => {
|
||||
fake = await startFakeOllama();
|
||||
const upper: LocalModelRuntimePin = { ...fake.pin(), sha256: fake.archive.sha256.toUpperCase() };
|
||||
expect(await ensureRuntimeInstalled({ pin: upper, paths, deps: deps() })).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it('continues a download that was cut, with a Range request from where the file ends', async () => {
|
||||
fake = await fakeWithLargeArchive({ cutAfterBytes: 1000 });
|
||||
const waits: number[] = [];
|
||||
const result = await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps(waits) });
|
||||
expect(result).toMatchObject({ ok: true, alreadyInstalled: false });
|
||||
// The first request from the start, the second for the rest.
|
||||
expect(archiveRanges(fake)).toEqual([undefined, 'bytes=1000-']);
|
||||
expect(waits).toEqual([500]);
|
||||
expect((await execFileAsync(paths.binary)).stdout).toBe('fake runtime\n');
|
||||
});
|
||||
|
||||
it('gives up after 3 continuations and keeps the partial file for the next run', async () => {
|
||||
expect(RUNTIME_DOWNLOAD_RETRIES).toBe(3);
|
||||
fake = await fakeWithLargeArchive({ cutAfterBytes: 1000, cutTimes: Infinity });
|
||||
const waits: number[] = [];
|
||||
const result = await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps(waits) });
|
||||
expect(result).toMatchObject({ ok: false, code: 'runtime-download-failed' });
|
||||
expect((result as { detail?: string }).detail).toBeTruthy();
|
||||
// The first request and three more, each from where the last one stopped.
|
||||
expect(archiveRanges(fake)).toEqual([undefined, 'bytes=1000-', 'bytes=2000-', 'bytes=3000-']);
|
||||
expect(waits).toEqual([500, 1000, 2000]);
|
||||
expect((await stat(paths.archivePartial)).size).toBe(4000);
|
||||
expect(existsSync(paths.runtimeDir)).toBe(false);
|
||||
|
||||
// "Try again": the next run continues the same file, it does not start over.
|
||||
fake.switches.archive.cutAfterBytes = undefined;
|
||||
expect(await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() })).toMatchObject({ ok: true });
|
||||
expect(archiveRanges(fake).at(-1)).toBe('bytes=4000-');
|
||||
expect((await execFileAsync(paths.binary)).stdout).toBe('fake runtime\n');
|
||||
});
|
||||
|
||||
it('fails after the same number of tries when the server keeps answering an error', async () => {
|
||||
fake = await startFakeOllama({ archive: { status: 503 } });
|
||||
const waits: number[] = [];
|
||||
const result = await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps(waits) });
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'runtime-download-failed',
|
||||
detail: 'The download server answered HTTP 503',
|
||||
});
|
||||
expect(fake.state.archiveRequests).toBe(4);
|
||||
expect(waits).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('starts over when the server ignores the Range request and sends the whole archive', async () => {
|
||||
fake = await fakeWithLargeArchive({ ignoreRange: true });
|
||||
// A partial file of an earlier run: the true first 1500 bytes.
|
||||
await mkdir(paths.runtimeRoot, { recursive: true });
|
||||
await writeFile(paths.archivePartial, fake.archive.body.subarray(0, 1500));
|
||||
const result = await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() });
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(archiveRanges(fake)).toEqual(['bytes=1500-']);
|
||||
// Appending the whole body to the 1500 bytes would not have had the pinned hash.
|
||||
expect((await execFileAsync(paths.binary)).stdout).toBe('fake runtime\n');
|
||||
});
|
||||
|
||||
it('proves the WHOLE file: a partial file that is not the start of the archive ends as damaged', async () => {
|
||||
fake = await fakeWithLargeArchive();
|
||||
await mkdir(paths.runtimeRoot, { recursive: true });
|
||||
await writeFile(paths.archivePartial, randomBytes(1500));
|
||||
const first = await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() });
|
||||
expect(first).toMatchObject({ ok: false, code: 'runtime-damaged' });
|
||||
expect(existsSync(paths.archivePartial)).toBe(false);
|
||||
expect(existsSync(paths.runtimeDir)).toBe(false);
|
||||
// The damaged file is gone, so the next run downloads from the first byte.
|
||||
expect(await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() })).toMatchObject({ ok: true });
|
||||
expect(archiveRanges(fake)).toEqual(['bytes=1500-', undefined]);
|
||||
});
|
||||
|
||||
it('throws away a partial file that is larger than the archive', async () => {
|
||||
fake = await startFakeOllama();
|
||||
await mkdir(paths.runtimeRoot, { recursive: true });
|
||||
await writeFile(paths.archivePartial, randomBytes(fake.archive.size + 100));
|
||||
expect(await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() })).toMatchObject({ ok: true });
|
||||
expect(archiveRanges(fake)).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it('verifies a file that is already complete without asking the server', async () => {
|
||||
fake = await startFakeOllama();
|
||||
await mkdir(paths.runtimeRoot, { recursive: true });
|
||||
await writeFile(paths.archivePartial, fake.archive.body);
|
||||
expect(await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() })).toMatchObject({ ok: true });
|
||||
expect(fake.state.archiveRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses a download whose size is not the pinned one, before it reads it', async () => {
|
||||
fake = await startFakeOllama();
|
||||
const shortPin: LocalModelRuntimePin = { ...fake.pin(), bytes: fake.archive.size - 10 };
|
||||
const result = await ensureRuntimeInstalled({ pin: shortPin, paths, deps: deps() });
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'runtime-damaged',
|
||||
detail: `The download is ${fake.archive.size} bytes, the pinned archive has ${fake.archive.size - 10}`,
|
||||
});
|
||||
expect(existsSync(paths.archivePartial)).toBe(false);
|
||||
expect(fake.state.archiveRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('refuses a body that grows past the pinned size, when the server names no size', async () => {
|
||||
fake = await startFakeOllama();
|
||||
const pin = fake.pin();
|
||||
const body = fake.archive.body;
|
||||
let unpacked = 0;
|
||||
// The right archive followed by more bytes, as a stream without a content-length.
|
||||
const endless: typeof fetch = async () =>
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(body));
|
||||
controller.enqueue(new Uint8Array(1000));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
const result = await ensureRuntimeInstalled({
|
||||
pin,
|
||||
paths,
|
||||
deps: {
|
||||
fetch: endless,
|
||||
sleep: async () => undefined,
|
||||
untar: async () => {
|
||||
unpacked += 1;
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'runtime-damaged',
|
||||
detail: `The download is longer than the ${pin.bytes} bytes of the pinned archive`,
|
||||
});
|
||||
expect(unpacked).toBe(0);
|
||||
expect(existsSync(paths.archivePartial)).toBe(false);
|
||||
});
|
||||
|
||||
/** A server that sends the first `bytes` of the archive and then nothing more, until it is aborted. */
|
||||
function stallingFetch(server: FakeOllama, bytes: number, counter: { requests: number }): typeof fetch {
|
||||
return async (_input, init) => {
|
||||
counter.requests += 1;
|
||||
const signal = init?.signal ?? undefined;
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(server.archive.body.subarray(0, bytes)));
|
||||
signal?.addEventListener('abort', () => controller.error(new DOMException('aborted', 'AbortError')));
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
it('stops when the caller cancels, and the next run continues the same file', async () => {
|
||||
fake = await fakeWithLargeArchive();
|
||||
const counter = { requests: 0 };
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 40);
|
||||
const cancelled = await ensureRuntimeInstalled({
|
||||
pin: fake.pin(),
|
||||
paths,
|
||||
signal: controller.signal,
|
||||
deps: { fetch: stallingFetch(fake, 2000, counter), sleep: async () => undefined },
|
||||
});
|
||||
expect(cancelled).toEqual({ ok: false, code: 'cancelled' });
|
||||
// One request, no retry after a cancel, and what arrived is kept.
|
||||
expect(counter.requests).toBe(1);
|
||||
expect((await stat(paths.archivePartial)).size).toBe(2000);
|
||||
expect(existsSync(paths.runtimeDir)).toBe(false);
|
||||
|
||||
expect(await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() })).toMatchObject({ ok: true });
|
||||
expect(archiveRanges(fake)).toEqual(['bytes=2000-']);
|
||||
});
|
||||
|
||||
it('answers cancelled at once for a signal that is already aborted', async () => {
|
||||
fake = await startFakeOllama();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const result = await ensureRuntimeInstalled({ pin: fake.pin(), paths, signal: controller.signal, deps: deps() });
|
||||
expect(result).toEqual({ ok: false, code: 'cancelled' });
|
||||
expect(fake.state.archiveRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('treats a download that delivers nothing as cut, a bounded number of times', async () => {
|
||||
fake = await fakeWithLargeArchive();
|
||||
const counter = { requests: 0 };
|
||||
const result = await ensureRuntimeInstalled({
|
||||
pin: fake.pin(),
|
||||
paths,
|
||||
idleTimeoutMs: 30,
|
||||
deps: { fetch: stallingFetch(fake, 500, counter), sleep: async () => undefined },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'runtime-download-failed',
|
||||
detail: 'The download delivered nothing for 1 s',
|
||||
});
|
||||
expect(counter.requests).toBe(4);
|
||||
});
|
||||
|
||||
it('answers runtime-unpack-failed when tar fails, keeps the proven archive, and unpacks it next time', async () => {
|
||||
fake = await startFakeOllama();
|
||||
const failed = await ensureRuntimeInstalled({
|
||||
pin: fake.pin(),
|
||||
paths,
|
||||
deps: {
|
||||
...deps(),
|
||||
untar: async () => {
|
||||
throw new Error('tar: Error opening archive: Unrecognized archive format');
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(failed).toEqual({
|
||||
ok: false,
|
||||
code: 'runtime-unpack-failed',
|
||||
detail: 'tar: Error opening archive: Unrecognized archive format',
|
||||
});
|
||||
expect(existsSync(paths.unpackDir)).toBe(false);
|
||||
expect(existsSync(paths.runtimeDir)).toBe(false);
|
||||
expect((await stat(paths.archivePartial)).size).toBe(fake.archive.size);
|
||||
|
||||
expect(await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() })).toMatchObject({ ok: true });
|
||||
// Not downloaded a second time.
|
||||
expect(fake.state.archiveRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('answers disk-full when unpacking runs out of space', async () => {
|
||||
fake = await startFakeOllama();
|
||||
const result = await ensureRuntimeInstalled({
|
||||
pin: fake.pin(),
|
||||
paths,
|
||||
deps: {
|
||||
...deps(),
|
||||
untar: async () => {
|
||||
throw new Error("tar: ollama: Can't write: No space left on device");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, code: 'disk-full' });
|
||||
});
|
||||
|
||||
it('refuses an archive that holds no runtime binary', async () => {
|
||||
fake = await startFakeOllama({ archiveFiles: { 'README.txt': 'no binary here\n' } });
|
||||
const result = await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() });
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'runtime-unpack-failed',
|
||||
detail: 'The archive holds no ollama binary',
|
||||
});
|
||||
expect(existsSync(paths.runtimeDir)).toBe(false);
|
||||
expect(existsSync(paths.unpackDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('replaces a runtime folder that an interrupted unpack left without its binary', async () => {
|
||||
fake = await startFakeOllama();
|
||||
await mkdir(paths.runtimeDir, { recursive: true });
|
||||
await writeFile(join(paths.runtimeDir, 'half-written'), 'x');
|
||||
await mkdir(paths.unpackDir, { recursive: true });
|
||||
await writeFile(join(paths.unpackDir, 'stale'), 'x');
|
||||
expect(await isRuntimeInstalled(fake.pin(), paths)).toBe(false);
|
||||
expect(await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() })).toMatchObject({ ok: true });
|
||||
expect(existsSync(join(paths.runtimeDir, 'half-written'))).toBe(false);
|
||||
expect(existsSync(join(paths.runtimeDir, 'stale'))).toBe(false);
|
||||
expect(await readFile(paths.binary, 'utf8')).toContain('fake runtime');
|
||||
});
|
||||
|
||||
it('writes the download where only the owner can read it, inside the one folder', async () => {
|
||||
fake = await fakeWithLargeArchive({ cutAfterBytes: 1000, cutTimes: Infinity });
|
||||
await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() });
|
||||
expect((await stat(paths.archivePartial)).mode & 0o077).toBe(0);
|
||||
expect(paths.archivePartial.startsWith(paths.root)).toBe(true);
|
||||
});
|
||||
|
||||
it('never throws: a version that is no version is an answer', async () => {
|
||||
const badPin = { ...OLLAMA_RUNTIME_PIN, version: '../../etc' };
|
||||
expect(await isRuntimeInstalled(badPin)).toBe(false);
|
||||
expect(await ensureRuntimeInstalled({ pin: badPin, deps: deps() })).toMatchObject({
|
||||
ok: false,
|
||||
code: 'runtime-download-failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- The port ----------
|
||||
|
||||
describe('chooseRuntimePort', () => {
|
||||
let holder: Server | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => (holder ? holder.close(() => resolve()) : resolve()));
|
||||
holder = undefined;
|
||||
});
|
||||
|
||||
/** Binds a loopback port the system picks and keeps it. */
|
||||
async function holdPort(): Promise<number> {
|
||||
holder = createServer();
|
||||
await new Promise<void>((resolve) => holder?.listen(0, '127.0.0.1', () => resolve()));
|
||||
return (holder.address() as AddressInfo).port;
|
||||
}
|
||||
|
||||
it('takes the first port of the span that is free, in order', async () => {
|
||||
const asked: number[] = [];
|
||||
const port = await chooseRuntimePort({
|
||||
isFree: async (candidate) => {
|
||||
asked.push(candidate);
|
||||
return candidate >= 11438;
|
||||
},
|
||||
});
|
||||
expect(port).toBe(11438);
|
||||
expect(asked).toEqual([11435, 11436, 11437, 11438]);
|
||||
});
|
||||
|
||||
it('starts at 11435 and tries 20 ports, never 11434', async () => {
|
||||
const asked: number[] = [];
|
||||
const port = await chooseRuntimePort({
|
||||
isFree: async (candidate) => {
|
||||
asked.push(candidate);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
expect(port).toBeUndefined();
|
||||
expect(asked).toHaveLength(20);
|
||||
expect(asked[0]).toBe(11435);
|
||||
expect(asked.at(-1)).toBe(11454);
|
||||
expect(asked).not.toContain(11434);
|
||||
});
|
||||
|
||||
it('skips the ports it is told to avoid: "Use another port"', async () => {
|
||||
const asked: number[] = [];
|
||||
const port = await chooseRuntimePort({
|
||||
avoid: [11435, 11436],
|
||||
isFree: async (candidate) => {
|
||||
asked.push(candidate);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
expect(port).toBe(11437);
|
||||
expect(asked).toEqual([11437]);
|
||||
});
|
||||
|
||||
it('does not take a port whose probe failed for free', async () => {
|
||||
const port = await chooseRuntimePort({
|
||||
first: 100,
|
||||
span: 3,
|
||||
isFree: async (candidate) => {
|
||||
if (candidate === 100) throw new Error('EACCES');
|
||||
return candidate === 102;
|
||||
},
|
||||
});
|
||||
expect(port).toBe(102);
|
||||
});
|
||||
|
||||
it('decides by BINDING: a held port is not free, and is free again once it is let go', async () => {
|
||||
const port = await holdPort();
|
||||
expect(await isLoopbackPortFree(port)).toBe(false);
|
||||
expect(await chooseRuntimePort({ first: port, span: 1 })).toBeUndefined();
|
||||
await new Promise<void>((resolve) => holder?.close(() => resolve()));
|
||||
holder = undefined;
|
||||
expect(await isLoopbackPortFree(port)).toBe(true);
|
||||
expect(await chooseRuntimePort({ first: port, span: 1 })).toBe(port);
|
||||
// The probe let go of it again: it can be bound right away.
|
||||
expect(await isLoopbackPortFree(port)).toBe(true);
|
||||
});
|
||||
|
||||
it('calls a number that is no port not free', async () => {
|
||||
for (const port of [0, -1, 65536, 1.5, Number.NaN])
|
||||
expect(await isLoopbackPortFree(port), String(port)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- The removal ----------
|
||||
|
||||
describe('removeLocalModelFiles', () => {
|
||||
/** Everything an install leaves on disk, plus the state file beside the folder. */
|
||||
async function installTree(): Promise<void> {
|
||||
await mkdir(join(paths.runtimeDir, 'lib'), { recursive: true });
|
||||
await writeFile(paths.binary, '#!/bin/sh\n');
|
||||
await writeFile(join(paths.runtimeDir, 'lib', 'libmlx.dylib'), 'x');
|
||||
await mkdir(join(paths.runtimeRoot, 'ollama-0.39.0'), { recursive: true });
|
||||
await writeFile(join(paths.runtimeRoot, 'ollama-0.39.0', 'ollama'), 'old');
|
||||
await writeFile(paths.archivePartial, 'partial');
|
||||
await mkdir(join(paths.homeDir, '.ollama'), { recursive: true });
|
||||
await writeFile(join(paths.homeDir, '.ollama', 'id_ed25519'), 'private key');
|
||||
await mkdir(join(paths.modelsDir, 'blobs'), { recursive: true });
|
||||
await writeFile(join(paths.modelsDir, 'blobs', 'sha256-abc'), 'weights');
|
||||
await writeFile(paths.stateFile, '{"schema":1,"state":"installed"}');
|
||||
}
|
||||
|
||||
it('removes the runtime, every version of it, the archive and the private home, and keeps the models', async () => {
|
||||
await installTree();
|
||||
await removeLocalModelFiles({ models: false, paths });
|
||||
expect(existsSync(paths.runtimeRoot)).toBe(false);
|
||||
expect(existsSync(paths.homeDir)).toBe(false);
|
||||
expect(await readFile(join(paths.modelsDir, 'blobs', 'sha256-abc'), 'utf8')).toBe('weights');
|
||||
// The folder still holds the models, so it stays.
|
||||
expect(existsSync(paths.root)).toBe(true);
|
||||
});
|
||||
|
||||
it('removes the models too when asked, and then the folder itself', async () => {
|
||||
await installTree();
|
||||
await removeLocalModelFiles({ models: true, paths });
|
||||
expect(existsSync(paths.modelsDir)).toBe(false);
|
||||
expect(existsSync(paths.root)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the state file alone: that is the business of its one writer', async () => {
|
||||
await installTree();
|
||||
await removeLocalModelFiles({ models: true, paths });
|
||||
expect(await readFile(paths.stateFile, 'utf8')).toBe('{"schema":1,"state":"installed"}');
|
||||
await rm(paths.stateFile, { force: true });
|
||||
});
|
||||
|
||||
it('is fine with nothing to remove, and can be run twice', async () => {
|
||||
await removeLocalModelFiles({ models: true, paths });
|
||||
await installTree();
|
||||
await removeLocalModelFiles({ models: true, paths });
|
||||
await removeLocalModelFiles({ models: true, paths });
|
||||
expect(existsSync(paths.root)).toBe(false);
|
||||
await rm(paths.stateFile, { force: true });
|
||||
});
|
||||
|
||||
it('keeps a folder of the root that is not ours', async () => {
|
||||
await installTree();
|
||||
await mkdir(join(paths.root, 'notes'), { recursive: true });
|
||||
await writeFile(join(paths.root, 'notes', 'mine.txt'), 'keep');
|
||||
await removeLocalModelFiles({ models: true, paths });
|
||||
expect(await readFile(join(paths.root, 'notes', 'mine.txt'), 'utf8')).toBe('keep');
|
||||
await rm(paths.stateFile, { force: true });
|
||||
});
|
||||
|
||||
it('unlinks a symlink in the place of a folder and never deletes what it points at', async () => {
|
||||
const outside = await outsideDir();
|
||||
await writeFile(join(outside, 'precious.gguf'), 'the only copy');
|
||||
await mkdir(paths.root, { recursive: true });
|
||||
// The model store was moved to another disk and linked back.
|
||||
await symlink(outside, paths.modelsDir);
|
||||
await removeLocalModelFiles({ models: true, paths });
|
||||
expect(existsSync(paths.modelsDir)).toBe(false);
|
||||
expect(await readFile(join(outside, 'precious.gguf'), 'utf8')).toBe('the only copy');
|
||||
});
|
||||
|
||||
it('removes a symlink it meets inside a folder, not the tree behind it', async () => {
|
||||
const outside = await outsideDir();
|
||||
await writeFile(join(outside, 'keep.txt'), 'keep');
|
||||
await mkdir(paths.homeDir, { recursive: true });
|
||||
await symlink(outside, join(paths.homeDir, 'link-out'));
|
||||
await removeLocalModelFiles({ models: false, paths });
|
||||
expect(existsSync(paths.homeDir)).toBe(false);
|
||||
expect(await readFile(join(outside, 'keep.txt'), 'utf8')).toBe('keep');
|
||||
});
|
||||
|
||||
it('refuses a path that is not inside the one folder, and deletes nothing', async () => {
|
||||
const outside = await outsideDir();
|
||||
await writeFile(join(outside, 'keep.txt'), 'keep');
|
||||
await installTree();
|
||||
const cases: Partial<LocalModelPaths>[] = [
|
||||
{ runtimeRoot: outside },
|
||||
{ homeDir: outside },
|
||||
{ modelsDir: outside },
|
||||
// The folder itself, and a way back out of it.
|
||||
{ runtimeRoot: paths.root },
|
||||
{ homeDir: join(paths.root, '..', 'elsewhere') },
|
||||
];
|
||||
for (const change of cases) {
|
||||
await expect(removeLocalModelFiles({ models: true, paths: { ...paths, ...change } })).rejects.toThrow(/Refusing/);
|
||||
}
|
||||
expect(await readFile(join(outside, 'keep.txt'), 'utf8')).toBe('keep');
|
||||
// A refusal deletes NOTHING: every folder is judged before the first one is removed.
|
||||
expect(await readFile(paths.binary, 'utf8')).toBe('#!/bin/sh\n');
|
||||
expect(await readFile(join(paths.homeDir, '.ollama', 'id_ed25519'), 'utf8')).toBe('private key');
|
||||
expect(await readFile(join(paths.modelsDir, 'blobs', 'sha256-abc'), 'utf8')).toBe('weights');
|
||||
await rm(paths.stateFile, { force: true });
|
||||
});
|
||||
|
||||
it('refuses a folder whose real place is outside the real root', async () => {
|
||||
// The root is real, but `runtime` inside it is reached through a linked parent.
|
||||
const outside = await outsideDir();
|
||||
await mkdir(join(outside, 'runtime'), { recursive: true });
|
||||
await writeFile(join(outside, 'runtime', 'keep.txt'), 'keep');
|
||||
await mkdir(paths.root, { recursive: true });
|
||||
await symlink(outside, join(paths.root, 'nested'));
|
||||
const twisted: LocalModelPaths = { ...paths, runtimeRoot: join(paths.root, 'nested', 'runtime') };
|
||||
await expect(removeLocalModelFiles({ models: false, paths: twisted })).rejects.toThrow(/Refusing/);
|
||||
expect(await readFile(join(outside, 'runtime', 'keep.txt'), 'utf8')).toBe('keep');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,796 @@
|
||||
/**
|
||||
* @fileoverview Tests for the supervisor of src/local-model/runtime.ts: how Codeman starts,
|
||||
* watches, restarts and stops its model runtime.
|
||||
*
|
||||
* Nothing real runs here. The child process is the fake of test/fixtures/fake-ollama.ts
|
||||
* (`fake.spawn`): "starting" it makes the fake listen on the port of `OLLAMA_HOST` and write
|
||||
* the runtime's start-up log, and killing it closes that listener.
|
||||
*
|
||||
* Pinned:
|
||||
* - Start: one non-detached child, `<runtime folder>/ollama serve`, with the allowlisted
|
||||
* environment; it is ready when `/api/version` answers the pinned version; the device total
|
||||
* of its log is reported as the measured budget.
|
||||
* - The port: a listener that is not ours is `port-in-use` and is never signalled; our own
|
||||
* orphan (the stored pid runs a binary under our runtime folder) is adopted when it holds a
|
||||
* loaded model at the wanted context, and replaced otherwise.
|
||||
* - A child that exits by itself is restarted at most 3 times, then given up.
|
||||
* - Stop: SIGTERM, and nothing is restarted afterwards.
|
||||
* - Under vitest nothing spawns unless the test injects it.
|
||||
*
|
||||
* Port: none fixed (each test reserves a free loopback port).
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { createServer, type Server } from 'node:net';
|
||||
import type { ChildProcess, SpawnOptions } from 'node:child_process';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { localModelPaths, type LocalModelPaths } from '../src/local-model/paths.js';
|
||||
import {
|
||||
createRuntimeSupervisor,
|
||||
RUNTIME_MAX_RESTARTS,
|
||||
type RuntimeStatus,
|
||||
type RuntimeSupervisor,
|
||||
type RuntimeSupervisorDeps,
|
||||
} from '../src/local-model/runtime.js';
|
||||
import {
|
||||
createFakeOllama,
|
||||
FakeRuntimeChild,
|
||||
freeLoopbackPort,
|
||||
startFakeOllama,
|
||||
type FakeOllama,
|
||||
type FakeOllamaOptions,
|
||||
} from './fixtures/fake-ollama.js';
|
||||
|
||||
const CONTEXT = 131072;
|
||||
/** A pid of an "earlier server's" runtime. Above every real pid, like the fake's own. */
|
||||
const ORPHAN_PID = 9_800_000;
|
||||
|
||||
let paths: LocalModelPaths;
|
||||
let port: number;
|
||||
const fakes: FakeOllama[] = [];
|
||||
const supervisors: RuntimeSupervisor[] = [];
|
||||
const servers: Server[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
paths = localModelPaths('0.40.15');
|
||||
port = await freeLoopbackPort();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const supervisor of supervisors.splice(0)) await supervisor.stop();
|
||||
for (const fake of fakes.splice(0)) await fake.close();
|
||||
for (const server of servers.splice(0)) await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(paths.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A fake runtime that does not listen until it is "spawned". */
|
||||
function fakeRuntime(options: FakeOllamaOptions = {}): FakeOllama {
|
||||
const fake = createFakeOllama(options);
|
||||
fakes.push(fake);
|
||||
return fake;
|
||||
}
|
||||
|
||||
/** The children the fake has spawned so far. */
|
||||
function children(fake: FakeOllama): FakeRuntimeChild[] {
|
||||
return fake.state.children;
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
supervisor: RuntimeSupervisor;
|
||||
/** Every status `onChange` was called with. */
|
||||
changes: RuntimeStatus[];
|
||||
/** Every wait the supervisor asked for. */
|
||||
waits: number[];
|
||||
/** Every signal sent to a pid that is not our child. */
|
||||
kills: [number, NodeJS.Signals][];
|
||||
}
|
||||
|
||||
/** A supervisor whose child is `fake`. Waits are instant unless a test passes its own `sleep`. */
|
||||
function supervise(fake: FakeOllama, overrides: Partial<RuntimeSupervisorDeps> = {}): Harness {
|
||||
const changes: RuntimeStatus[] = [];
|
||||
const waits: number[] = [];
|
||||
const kills: [number, NodeJS.Signals][] = [];
|
||||
const supervisor = createRuntimeSupervisor({
|
||||
spawn: fake.spawn,
|
||||
fetch,
|
||||
env: { PATH: '/usr/bin:/bin' },
|
||||
pin: fake.pin(),
|
||||
paths,
|
||||
sleep: async (ms) => {
|
||||
waits.push(ms);
|
||||
},
|
||||
commandOfPid: async () => undefined,
|
||||
kill: (pid, signal) => {
|
||||
kills.push([pid, signal]);
|
||||
},
|
||||
onChange: (status) => changes.push(status),
|
||||
startTimeoutMs: 3_000,
|
||||
budgetWaitMs: 500,
|
||||
...overrides,
|
||||
});
|
||||
supervisors.push(supervisor);
|
||||
return { supervisor, changes, waits, kills };
|
||||
}
|
||||
|
||||
/** Waits until `check()` is true. Fails the test when it never is. */
|
||||
async function until(check: () => boolean, what: string, timeoutMs = 3_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!check()) {
|
||||
if (Date.now() > deadline) throw new Error(`Timed out waiting for: ${what}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
/** The command line `ps` would print for a runtime of ours. */
|
||||
function ownCommand(): string {
|
||||
return `${paths.binary} serve`;
|
||||
}
|
||||
|
||||
// ---------- Start ----------
|
||||
|
||||
describe('start', () => {
|
||||
it('starts one child, the runtime binary with "serve", and answers once it is up', async () => {
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor } = supervise(fake);
|
||||
const result = await supervisor.start({ port, contextLength: CONTEXT });
|
||||
|
||||
expect(children(fake)).toHaveLength(1);
|
||||
const child = children(fake)[0];
|
||||
expect(child.command).toBe(paths.binary);
|
||||
expect(child.args).toEqual(['serve']);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
pid: child.pid,
|
||||
port,
|
||||
orphan: 'none',
|
||||
// 37.4 GiB, read from the child's log line "inference compute".
|
||||
measuredTotalBytes: 40_157_944_217,
|
||||
});
|
||||
expect(supervisor.status()).toEqual({
|
||||
running: true,
|
||||
pid: child.pid,
|
||||
port,
|
||||
contextLength: CONTEXT,
|
||||
measuredTotalBytes: 40_157_944_217,
|
||||
restarts: 0,
|
||||
gaveUp: false,
|
||||
});
|
||||
expect(fake.port).toBe(port);
|
||||
});
|
||||
|
||||
it('starts the child non-detached, with pipes, in the private home', async () => {
|
||||
const fake = fakeRuntime();
|
||||
const seen: SpawnOptions[] = [];
|
||||
const { supervisor } = supervise(fake, {
|
||||
spawn: (command, args, options) => {
|
||||
seen.push(options);
|
||||
return fake.spawn(command, args, options);
|
||||
},
|
||||
});
|
||||
await supervisor.start({ port, contextLength: CONTEXT });
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].detached).toBe(false);
|
||||
expect(seen[0].stdio).toEqual(['ignore', 'pipe', 'pipe']);
|
||||
expect(seen[0].cwd).toBe(paths.homeDir);
|
||||
expect(seen[0]).not.toHaveProperty('shell');
|
||||
// The folders it needs exist before it starts.
|
||||
expect(existsSync(paths.homeDir)).toBe(true);
|
||||
expect(existsSync(paths.modelsDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('gives the child the allowlisted environment and nothing of the server beyond it', async () => {
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor } = supervise(fake, {
|
||||
env: {
|
||||
PATH: '/usr/bin:/bin',
|
||||
HOME: '/Users/arkon',
|
||||
LANG: 'en_US.UTF-8',
|
||||
CODEMAN_PASSWORD: 'hunter2',
|
||||
CODEMAN_API_URL: 'http://127.0.0.1:3000',
|
||||
OLLAMA_ORIGINS: '*',
|
||||
OLLAMA_HOST: '0.0.0.0:11434',
|
||||
OLLAMA_KEEP_ALIVE: '-1',
|
||||
ANTHROPIC_API_KEY: 'sk-ant-secret',
|
||||
},
|
||||
});
|
||||
await supervisor.start({ port, contextLength: 65536 });
|
||||
expect(children(fake)[0].env).toEqual({
|
||||
PATH: '/usr/bin:/bin',
|
||||
LANG: 'en_US.UTF-8',
|
||||
HOME: paths.homeDir,
|
||||
OLLAMA_HOST: `127.0.0.1:${port}`,
|
||||
OLLAMA_MODELS: paths.modelsDir,
|
||||
OLLAMA_CONTEXT_LENGTH: '65536',
|
||||
OLLAMA_NO_CLOUD: '1',
|
||||
OLLAMA_KEEP_ALIVE: '10m',
|
||||
OLLAMA_NUM_PARALLEL: '1',
|
||||
OLLAMA_MAX_LOADED_MODELS: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the budget the child logs, whatever the Mac', async () => {
|
||||
const fake = fakeRuntime({ spawn: { total: '11.8 GiB' } });
|
||||
const { supervisor } = supervise(fake);
|
||||
const result = await supervisor.start({ port, contextLength: CONTEXT });
|
||||
expect(result).toMatchObject({ ok: true, measuredTotalBytes: 12_670_153_523 });
|
||||
expect(supervisor.status().measuredTotalBytes).toBe(12_670_153_523);
|
||||
});
|
||||
|
||||
it('still starts when the child prints no budget line, and reports none', async () => {
|
||||
const fake = fakeRuntime({ spawn: { logsBudget: false } });
|
||||
const { supervisor } = supervise(fake, { budgetWaitMs: 60 });
|
||||
const result = await supervisor.start({ port, contextLength: CONTEXT });
|
||||
expect(result).toMatchObject({ ok: true, port });
|
||||
expect(result).not.toHaveProperty('measuredTotalBytes');
|
||||
expect(supervisor.status()).not.toHaveProperty('measuredTotalBytes');
|
||||
expect(supervisor.status().running).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent: a child that already runs as asked is kept', async () => {
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor } = supervise(fake);
|
||||
const first = await supervisor.start({ port, contextLength: CONTEXT });
|
||||
const second = await supervisor.start({ port, contextLength: CONTEXT });
|
||||
expect(second).toEqual(first);
|
||||
expect(children(fake)).toHaveLength(1);
|
||||
expect(children(fake)[0].signals).toEqual([]);
|
||||
});
|
||||
|
||||
it('replaces a child that runs with another context length', async () => {
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor } = supervise(fake);
|
||||
await supervisor.start({ port, contextLength: CONTEXT });
|
||||
const result = await supervisor.start({ port, contextLength: 65536 });
|
||||
expect(result).toMatchObject({ ok: true, orphan: 'none' });
|
||||
expect(children(fake)).toHaveLength(2);
|
||||
// The first got SIGTERM; the second runs with the new context.
|
||||
expect(children(fake)[0].signals).toEqual(['SIGTERM']);
|
||||
expect(children(fake)[1].env.OLLAMA_CONTEXT_LENGTH).toBe('65536');
|
||||
expect(supervisor.status()).toMatchObject({ running: true, pid: children(fake)[1].pid, contextLength: 65536 });
|
||||
// Replacing on purpose is not a crash.
|
||||
expect(supervisor.status().restarts).toBe(0);
|
||||
});
|
||||
|
||||
it('runs two starts one after the other, never two children at once', async () => {
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor } = supervise(fake);
|
||||
const [a, b] = await Promise.all([
|
||||
supervisor.start({ port, contextLength: CONTEXT }),
|
||||
supervisor.start({ port, contextLength: CONTEXT }),
|
||||
]);
|
||||
expect(a).toMatchObject({ ok: true });
|
||||
expect(b).toEqual(a);
|
||||
expect(children(fake)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses a port or a context length that is none, without starting anything', async () => {
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor } = supervise(fake);
|
||||
for (const bad of [0, -1, 70_000, 1.5, Number.NaN]) {
|
||||
expect(await supervisor.start({ port: bad, contextLength: CONTEXT }), String(bad)).toMatchObject({
|
||||
ok: false,
|
||||
code: 'runtime-start-failed',
|
||||
});
|
||||
}
|
||||
for (const bad of [0, -5, 1.5, Number.NaN]) {
|
||||
expect(await supervisor.start({ port, contextLength: bad }), String(bad)).toMatchObject({
|
||||
ok: false,
|
||||
code: 'runtime-start-failed',
|
||||
});
|
||||
}
|
||||
expect(children(fake)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('tells its caller every change, and survives a caller that throws', async () => {
|
||||
const fake = fakeRuntime();
|
||||
const seen: RuntimeStatus[] = [];
|
||||
const { supervisor } = supervise(fake, {
|
||||
onChange: (status) => {
|
||||
seen.push(status);
|
||||
throw new Error('the state file is read-only');
|
||||
},
|
||||
});
|
||||
const result = await supervisor.start({ port, contextLength: CONTEXT });
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
// The caller stores pid and port from this.
|
||||
expect(seen.at(-1)).toMatchObject({ running: true, pid: children(fake)[0].pid, port, contextLength: CONTEXT });
|
||||
await supervisor.stop();
|
||||
expect(seen.at(-1)).toEqual({ running: false, restarts: 0, gaveUp: false });
|
||||
});
|
||||
|
||||
it('hands out a copy of its status', async () => {
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor } = supervise(fake);
|
||||
await supervisor.start({ port, contextLength: CONTEXT });
|
||||
const status = supervisor.status();
|
||||
status.running = false;
|
||||
status.pid = 1;
|
||||
expect(supervisor.status()).toMatchObject({ running: true, pid: children(fake)[0].pid });
|
||||
});
|
||||
|
||||
it("writes no state file: storing pid and port is the caller's business", async () => {
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor } = supervise(fake);
|
||||
await supervisor.start({ port, contextLength: CONTEXT });
|
||||
await supervisor.stop();
|
||||
expect(existsSync(paths.stateFile)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- A start that fails ----------
|
||||
|
||||
describe('a start that fails', () => {
|
||||
it('answers runtime-start-failed when the child never answers, and does not leave it running', async () => {
|
||||
const fake = fakeRuntime({ spawn: { neverListens: true } });
|
||||
const { supervisor } = supervise(fake, { startTimeoutMs: 150 });
|
||||
const result = await supervisor.start({ port, contextLength: CONTEXT });
|
||||
expect(result).toMatchObject({ ok: false, code: 'runtime-start-failed' });
|
||||
const detail = (result as { detail?: string }).detail ?? '';
|
||||
expect(detail).toContain('did not answer');
|
||||
// The last lines of its log are the detail.
|
||||
expect(detail).toContain('Listening on');
|
||||
expect(children(fake)).toHaveLength(1);
|
||||
expect(children(fake)[0].signals).toEqual(['SIGTERM']);
|
||||
expect(supervisor.status()).toEqual({ running: false, restarts: 0, gaveUp: false });
|
||||
});
|
||||
|
||||
it('answers runtime-start-failed when the child exits at once, with what it printed', async () => {
|
||||
const fake = fakeRuntime({ spawn: { exitsAtOnce: 2 } });
|
||||
const { supervisor } = supervise(fake);
|
||||
const result = await supervisor.start({ port, contextLength: CONTEXT });
|
||||
expect(result).toMatchObject({ ok: false, code: 'runtime-start-failed' });
|
||||
const detail = (result as { detail?: string }).detail ?? '';
|
||||
expect(detail).toContain('exited with code 2');
|
||||
expect(detail).toContain('address already in use');
|
||||
// A start that failed is not a crash to recover from: nothing is started again.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(children(fake)).toHaveLength(1);
|
||||
expect(supervisor.status()).toMatchObject({ running: false, restarts: 0, gaveUp: false });
|
||||
});
|
||||
|
||||
it('answers runtime-start-failed when a runtime of another version answers', async () => {
|
||||
const fake = fakeRuntime({ version: '0.39.2' });
|
||||
const { supervisor } = supervise(fake, { pin: { ...fake.pin(), version: '0.40.15' } });
|
||||
const result = await supervisor.start({ port, contextLength: CONTEXT });
|
||||
expect(result).toMatchObject({ ok: false, code: 'runtime-start-failed' });
|
||||
expect((result as { detail?: string }).detail).toContain('0.39.2');
|
||||
expect(children(fake)[0].signals).toEqual(['SIGTERM']);
|
||||
});
|
||||
|
||||
it('answers runtime-start-failed when the binary cannot be started at all', async () => {
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor } = supervise(fake, {
|
||||
spawn: () => {
|
||||
throw new Error('spawn EACCES');
|
||||
},
|
||||
});
|
||||
expect(await supervisor.start({ port, contextLength: CONTEXT })).toEqual({
|
||||
ok: false,
|
||||
code: 'runtime-start-failed',
|
||||
detail: 'spawn EACCES',
|
||||
});
|
||||
});
|
||||
|
||||
it('spawns nothing under vitest unless the test injects a spawn', async () => {
|
||||
// No `spawn` and no `fetch` given: the defaults refuse here.
|
||||
const supervisor = createRuntimeSupervisor({ paths, startTimeoutMs: 200 });
|
||||
supervisors.push(supervisor);
|
||||
const result = await supervisor.start({ port, contextLength: CONTEXT });
|
||||
// The refusal itself, not a failed attempt to run a binary that is not there.
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'runtime-start-failed',
|
||||
detail: 'spawn is not available under vitest: the test must inject its own',
|
||||
});
|
||||
expect(supervisor.status().running).toBe(false);
|
||||
});
|
||||
|
||||
it('gives up a start at once when stop is called while it waits', async () => {
|
||||
const fake = fakeRuntime({ spawn: { neverListens: true } });
|
||||
const { supervisor } = supervise(fake, {
|
||||
startTimeoutMs: 20_000,
|
||||
sleep: () => new Promise((resolve) => setTimeout(resolve, 10)),
|
||||
});
|
||||
const starting = supervisor.start({ port, contextLength: CONTEXT });
|
||||
await until(() => children(fake).length === 1, 'the child to be spawned');
|
||||
const began = Date.now();
|
||||
await supervisor.stop();
|
||||
const result = await starting;
|
||||
expect(result).toMatchObject({ ok: false, code: 'runtime-start-failed' });
|
||||
// Not after the 20 seconds a start may take.
|
||||
expect(Date.now() - began).toBeLessThan(5_000);
|
||||
expect(children(fake)[0].signals).toEqual(['SIGTERM']);
|
||||
expect(supervisor.status().running).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- The port ----------
|
||||
|
||||
describe('the port', () => {
|
||||
/** A runtime that listens on `port` already, as an earlier server's would. */
|
||||
async function listeningRuntime(options: FakeOllamaOptions = {}): Promise<FakeOllama> {
|
||||
const other = createFakeOllama(options);
|
||||
fakes.push(other);
|
||||
await other.listen(port);
|
||||
return other;
|
||||
}
|
||||
|
||||
it('answers port-in-use for a runtime that is not ours, and does not touch it', async () => {
|
||||
// The user's own runtime on our port. No pid of ours is stored.
|
||||
const theirs = await listeningRuntime();
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor, kills } = supervise(fake);
|
||||
const result = await supervisor.start({ port, contextLength: CONTEXT });
|
||||
// Told apart from any other program on the port: it answers as a runtime, and is not ours.
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'port-in-use',
|
||||
detail: `A model runtime that Codeman did not start answers on port ${port} (version 0.40.15)`,
|
||||
});
|
||||
expect(children(fake)).toHaveLength(0);
|
||||
expect(kills).toEqual([]);
|
||||
expect(theirs.listening).toBe(true);
|
||||
expect(supervisor.status().running).toBe(false);
|
||||
});
|
||||
|
||||
it('answers port-in-use when the stored pid now runs something else', async () => {
|
||||
const theirs = await listeningRuntime();
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor, kills } = supervise(fake, {
|
||||
// The pid was reused, or it is a runtime outside our folder.
|
||||
commandOfPid: async () => '/usr/local/bin/ollama serve',
|
||||
});
|
||||
const result = await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID });
|
||||
expect(result).toMatchObject({ ok: false, code: 'port-in-use' });
|
||||
expect(kills).toEqual([]);
|
||||
expect(theirs.listening).toBe(true);
|
||||
expect(children(fake)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('answers port-in-use for a listener that is no runtime at all', async () => {
|
||||
// Some other program: it takes the connection and hangs up.
|
||||
const holder = createServer((socket) => socket.destroy());
|
||||
servers.push(holder);
|
||||
await new Promise<void>((resolve) => holder.listen(port, '127.0.0.1', () => resolve()));
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor } = supervise(fake);
|
||||
expect(await supervisor.start({ port, contextLength: CONTEXT })).toEqual({
|
||||
ok: false,
|
||||
code: 'port-in-use',
|
||||
detail: `Another program holds port ${port}`,
|
||||
});
|
||||
expect(children(fake)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ADOPTS its own orphan when that holds a loaded model at the wanted context', async () => {
|
||||
const orphan = await listeningRuntime({ installed: true });
|
||||
orphan.state.loaded = true;
|
||||
let gone = false;
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor, kills, changes } = supervise(fake, {
|
||||
commandOfPid: async (pid) => (pid === ORPHAN_PID && !gone ? ownCommand() : undefined),
|
||||
});
|
||||
const result = await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID });
|
||||
// Kept as it is: nothing spawned, nothing signalled, the loaded model stays loaded.
|
||||
expect(result).toEqual({ ok: true, pid: ORPHAN_PID, port, orphan: 'adopted' });
|
||||
expect(children(fake)).toHaveLength(0);
|
||||
expect(kills).toEqual([]);
|
||||
expect(orphan.listening).toBe(true);
|
||||
// Its log is not ours to read, so there is no measured budget.
|
||||
expect(supervisor.status()).toEqual({
|
||||
running: true,
|
||||
pid: ORPHAN_PID,
|
||||
port,
|
||||
contextLength: CONTEXT,
|
||||
restarts: 0,
|
||||
gaveUp: false,
|
||||
});
|
||||
expect(changes.at(-1)).toMatchObject({ running: true, pid: ORPHAN_PID });
|
||||
// So that the stop after this test finds it gone and does not wait out its grace period.
|
||||
gone = true;
|
||||
});
|
||||
|
||||
it('stops an adopted orphan by its pid', async () => {
|
||||
const orphan = await listeningRuntime({ installed: true });
|
||||
orphan.state.loaded = true;
|
||||
let gone = false;
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor, kills } = supervise(fake, {
|
||||
commandOfPid: async (pid) => (pid === ORPHAN_PID && !gone ? ownCommand() : undefined),
|
||||
kill: (pid, signal) => {
|
||||
kills.push([pid, signal]);
|
||||
gone = true;
|
||||
},
|
||||
});
|
||||
await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID });
|
||||
await supervisor.stop();
|
||||
expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]);
|
||||
expect(supervisor.status()).toEqual({ running: false, restarts: 0, gaveUp: false });
|
||||
});
|
||||
|
||||
/** Starts with an orphan on the port that is ours and goes away when it is signalled. */
|
||||
async function startOverOrphan(orphanOptions: FakeOllamaOptions, loaded: boolean) {
|
||||
const orphan = await listeningRuntime(orphanOptions);
|
||||
orphan.state.loaded = loaded;
|
||||
let gone = false;
|
||||
const fake = fakeRuntime();
|
||||
const harness = supervise(fake, {
|
||||
commandOfPid: async (pid) => (pid === ORPHAN_PID && !gone ? ownCommand() : undefined),
|
||||
kill: (pid, signal) => {
|
||||
harness.kills.push([pid, signal]);
|
||||
gone = true;
|
||||
void orphan.close();
|
||||
},
|
||||
});
|
||||
const result = await harness.supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID });
|
||||
return { orphan, fake, result, ...harness };
|
||||
}
|
||||
|
||||
it('REPLACES its own orphan when that has no model loaded: nothing is lost, and the budget is read again', async () => {
|
||||
const { orphan, fake, result, kills, supervisor } = await startOverOrphan({ installed: true }, false);
|
||||
expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]);
|
||||
expect(orphan.listening).toBe(false);
|
||||
expect(children(fake)).toHaveLength(1);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
pid: children(fake)[0].pid,
|
||||
port,
|
||||
orphan: 'replaced',
|
||||
measuredTotalBytes: 40_157_944_217,
|
||||
});
|
||||
expect(supervisor.status()).toMatchObject({ running: true, pid: children(fake)[0].pid });
|
||||
});
|
||||
|
||||
it('replaces its own orphan when the model is loaded at another context', async () => {
|
||||
const { fake, result, kills } = await startOverOrphan({ installed: true, contextLength: 32768 }, true);
|
||||
expect(result).toMatchObject({ ok: true, orphan: 'replaced' });
|
||||
expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]);
|
||||
expect(children(fake)[0].env.OLLAMA_CONTEXT_LENGTH).toBe(String(CONTEXT));
|
||||
});
|
||||
|
||||
it('replaces its own orphan when it is another version than the pinned one', async () => {
|
||||
const { fake, result, kills } = await startOverOrphan({ installed: true, version: '0.39.2' }, true);
|
||||
expect(result).toMatchObject({ ok: true, orphan: 'replaced' });
|
||||
expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]);
|
||||
expect(children(fake)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('replaces its own orphan that no longer answers, and starts on the free port', async () => {
|
||||
// The stored pid is a runtime of ours, but nothing listens on the port.
|
||||
let gone = false;
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor, kills } = supervise(fake, {
|
||||
commandOfPid: async (pid) => (pid === ORPHAN_PID && !gone ? ownCommand() : undefined),
|
||||
kill: (pid, signal) => {
|
||||
kills.push([pid, signal]);
|
||||
gone = true;
|
||||
},
|
||||
});
|
||||
const result = await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID });
|
||||
expect(result).toMatchObject({ ok: true, orphan: 'replaced' });
|
||||
expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]);
|
||||
});
|
||||
|
||||
it('never signals a pid whose command is not under our runtime folder', async () => {
|
||||
for (const command of [
|
||||
'/usr/local/bin/ollama serve',
|
||||
`${paths.runtimeRoot}-other/ollama serve`,
|
||||
`/bin/sh -c ${paths.binary} serve`,
|
||||
undefined,
|
||||
]) {
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor, kills } = supervise(fake, { commandOfPid: async () => command });
|
||||
const result = await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID });
|
||||
expect(result, String(command)).toMatchObject({ ok: true, orphan: 'none' });
|
||||
expect(kills, String(command)).toEqual([]);
|
||||
await supervisor.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('takes over from an adopted orphan that dies: the next runtime is a child of its own', async () => {
|
||||
const orphan = await listeningRuntime({ installed: true });
|
||||
orphan.state.loaded = true;
|
||||
let gone = false;
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor } = supervise(fake, {
|
||||
commandOfPid: async (pid) => (pid === ORPHAN_PID && !gone ? ownCommand() : undefined),
|
||||
orphanPollMs: 15,
|
||||
});
|
||||
expect(await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID })).toMatchObject({
|
||||
orphan: 'adopted',
|
||||
});
|
||||
// The orphan dies by itself.
|
||||
await orphan.close();
|
||||
gone = true;
|
||||
await until(() => children(fake).length === 1 && supervisor.status().running, 'a child of our own to run');
|
||||
expect(supervisor.status()).toMatchObject({
|
||||
running: true,
|
||||
pid: children(fake)[0].pid,
|
||||
port,
|
||||
restarts: 1,
|
||||
measuredTotalBytes: 40_157_944_217,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Restart ----------
|
||||
|
||||
describe('a child that exits by itself', () => {
|
||||
it('is started again, with the same port and context', async () => {
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor, waits, changes } = supervise(fake);
|
||||
await supervisor.start({ port, contextLength: CONTEXT });
|
||||
const first = children(fake)[0];
|
||||
|
||||
first.crash(1);
|
||||
await until(() => children(fake).length === 2 && supervisor.status().running, 'the runtime to run again');
|
||||
|
||||
const second = children(fake)[1];
|
||||
expect(second.env.OLLAMA_HOST).toBe(`127.0.0.1:${port}`);
|
||||
expect(second.env.OLLAMA_CONTEXT_LENGTH).toBe(String(CONTEXT));
|
||||
expect(supervisor.status()).toMatchObject({ running: true, pid: second.pid, restarts: 1, gaveUp: false });
|
||||
// It waited a second before starting it again.
|
||||
expect(waits).toContain(1000);
|
||||
// The caller was told that it stopped, and that it runs again.
|
||||
const running = changes.map((status) => status.running);
|
||||
expect(running.indexOf(false, running.indexOf(true))).toBeGreaterThan(0);
|
||||
expect(running.at(-1)).toBe(true);
|
||||
// Nobody signalled the one that crashed.
|
||||
expect(first.signals).toEqual([]);
|
||||
});
|
||||
|
||||
it('is started again at most 3 times, then given up', async () => {
|
||||
expect(RUNTIME_MAX_RESTARTS).toBe(3);
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor, changes } = supervise(fake);
|
||||
await supervisor.start({ port, contextLength: CONTEXT });
|
||||
|
||||
for (let crash = 1; crash <= 3; crash++) {
|
||||
children(fake).at(-1)?.crash(1);
|
||||
await until(() => children(fake).length === crash + 1 && supervisor.status().running, `restart number ${crash}`);
|
||||
expect(supervisor.status()).toMatchObject({ running: true, restarts: crash, gaveUp: false });
|
||||
}
|
||||
|
||||
// The fourth exit is one too many.
|
||||
children(fake).at(-1)?.crash(1);
|
||||
await until(() => supervisor.status().gaveUp, 'the supervisor to give up');
|
||||
expect(supervisor.status()).toEqual({
|
||||
running: false,
|
||||
port,
|
||||
contextLength: CONTEXT,
|
||||
measuredTotalBytes: 40_157_944_217,
|
||||
restarts: 3,
|
||||
gaveUp: true,
|
||||
});
|
||||
// The first child and three restarts, and no fifth.
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
expect(children(fake)).toHaveLength(4);
|
||||
expect(changes.at(-1)).toMatchObject({ running: false, gaveUp: true, restarts: 3 });
|
||||
});
|
||||
|
||||
it('counts a restart that does not come up as one of the three', async () => {
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor } = supervise(fake, { startTimeoutMs: 100 });
|
||||
await supervisor.start({ port, contextLength: CONTEXT });
|
||||
// From now on the runtime exits as soon as it is started.
|
||||
fake.switches.spawn.exitsAtOnce = 1;
|
||||
children(fake)[0].crash(1);
|
||||
await until(() => supervisor.status().gaveUp, 'the supervisor to give up');
|
||||
expect(children(fake)).toHaveLength(4);
|
||||
expect(supervisor.status()).toMatchObject({ running: false, restarts: 3, gaveUp: true });
|
||||
});
|
||||
|
||||
it('starts counting again after a start that was asked for', async () => {
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor } = supervise(fake, { startTimeoutMs: 100 });
|
||||
await supervisor.start({ port, contextLength: CONTEXT });
|
||||
fake.switches.spawn.exitsAtOnce = 1;
|
||||
children(fake)[0].crash(1);
|
||||
await until(() => supervisor.status().gaveUp, 'the supervisor to give up');
|
||||
|
||||
// The person presses Start: it runs again, and the count is back at 0.
|
||||
fake.switches.spawn.exitsAtOnce = undefined;
|
||||
const result = await supervisor.start({ port, contextLength: CONTEXT });
|
||||
expect(result).toMatchObject({ ok: true, orphan: 'none' });
|
||||
expect(supervisor.status()).toMatchObject({ running: true, restarts: 0, gaveUp: false });
|
||||
});
|
||||
|
||||
it('is not started again once stop was called, also while it waits to restart', async () => {
|
||||
const fake = fakeRuntime();
|
||||
let release: () => void = () => undefined;
|
||||
const { supervisor } = supervise(fake, {
|
||||
// The wait before a restart does not end by itself; the polls of a start still do.
|
||||
sleep: (ms) =>
|
||||
ms === 1000
|
||||
? new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
})
|
||||
: Promise.resolve(),
|
||||
});
|
||||
await supervisor.start({ port, contextLength: CONTEXT });
|
||||
children(fake)[0].crash(1);
|
||||
await until(() => supervisor.status().restarts === 1, 'the restart to be scheduled');
|
||||
|
||||
const stopping = supervisor.stop();
|
||||
release();
|
||||
await stopping;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(children(fake)).toHaveLength(1);
|
||||
expect(supervisor.status()).toEqual({ running: false, restarts: 0, gaveUp: false });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Stop ----------
|
||||
|
||||
describe('stop', () => {
|
||||
it('sends the child SIGTERM and resolves once it is gone', async () => {
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor, changes } = supervise(fake);
|
||||
await supervisor.start({ port, contextLength: CONTEXT });
|
||||
const child = children(fake)[0];
|
||||
|
||||
await supervisor.stop();
|
||||
|
||||
expect(child.signals).toEqual(['SIGTERM']);
|
||||
expect(child.signalCode).toBe('SIGTERM');
|
||||
// Gone, and the port is free again.
|
||||
expect(fake.listening).toBe(false);
|
||||
await expect(fetch(`http://127.0.0.1:${port}/api/version`)).rejects.toThrow();
|
||||
expect(supervisor.status()).toEqual({ running: false, restarts: 0, gaveUp: false });
|
||||
expect(changes.at(-1)).toEqual({ running: false, restarts: 0, gaveUp: false });
|
||||
});
|
||||
|
||||
it('does not take the stop for a crash: nothing is started again', async () => {
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor, waits } = supervise(fake);
|
||||
await supervisor.start({ port, contextLength: CONTEXT });
|
||||
await supervisor.stop();
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
expect(children(fake)).toHaveLength(1);
|
||||
expect(waits).not.toContain(1000);
|
||||
});
|
||||
|
||||
it('is safe when nothing runs, and twice in a row', async () => {
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor } = supervise(fake);
|
||||
await supervisor.stop();
|
||||
await supervisor.start({ port, contextLength: CONTEXT });
|
||||
await Promise.all([supervisor.stop(), supervisor.stop()]);
|
||||
await supervisor.stop();
|
||||
expect(children(fake)[0].signals).toEqual(['SIGTERM']);
|
||||
expect(supervisor.status().running).toBe(false);
|
||||
});
|
||||
|
||||
it('can be started again after a stop', async () => {
|
||||
const fake = fakeRuntime();
|
||||
const { supervisor } = supervise(fake);
|
||||
await supervisor.start({ port, contextLength: CONTEXT });
|
||||
await supervisor.stop();
|
||||
const again = await supervisor.start({ port, contextLength: CONTEXT });
|
||||
expect(again).toMatchObject({ ok: true, pid: children(fake)[1].pid, orphan: 'none' });
|
||||
expect(children(fake)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('kills the child when the server process exits without its shutdown', async () => {
|
||||
// The last resort for a forced exit: a listener on the process, for as long as the child lives.
|
||||
const fake = fakeRuntime();
|
||||
const before = process.listenerCount('exit');
|
||||
const { supervisor } = supervise(fake);
|
||||
await supervisor.start({ port, contextLength: CONTEXT });
|
||||
expect(process.listenerCount('exit')).toBe(before + 1);
|
||||
const child = children(fake)[0] as unknown as ChildProcess & FakeRuntimeChild;
|
||||
|
||||
// What Node does on `process.exit()`: it calls the listeners.
|
||||
const added = process.listeners('exit').at(-1) as () => void;
|
||||
const exited = new Promise<void>((resolve) => child.once('exit', () => resolve()));
|
||||
added();
|
||||
await exited;
|
||||
expect(child.signals).toEqual(['SIGTERM']);
|
||||
// The listener goes with its child. (This process did not really exit, so the supervisor
|
||||
// took the exit for a crash and started another child, which has a listener of its own.)
|
||||
expect(process.listeners('exit')).not.toContain(added);
|
||||
await supervisor.stop();
|
||||
expect(process.listenerCount('exit')).toBe(before);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user