fix(obsidivm): actionable error when the range service is unreachable (#156) (#158)

* test(obsidivm): RED — bridge should give an actionable error when the service is down

The obsidivm:* benchmarks fetch from the OBSIDIVM range service; when it is not
running the bridge throws a bare 'fetch failed' (surfaced as 'FATAL: fetch
failed'), naming neither the service, the URL, nor how to start it. Pins that a
connection failure becomes an actionable, self-service error.

Fails on main: the message is 'fetch failed'.

Refs #156

* fix(obsidivm): actionable error when the range service is unreachable

The obsidivm:* benchmarks fetch from the OBSIDIVM range service; when it is not
running the bridge's shared call() let the raw 'fetch failed' propagate, which
surfaced as an opaque 'FATAL: fetch failed' (#156) naming neither the service,
the URL, nor how to start it — even in --hunter stub, whose grading step still
needs it. Wrap the fetch in call() (the single choke point for every OBSIDIVM
request) so a connection failure becomes: 'OBSIDIVM service unreachable at <url>
... Start it per docs/OBSIDIVM.md (python3 range.py, listens on :4200), or set
OBSIDIVM_URL', flagged err.unreachable to distinguish it from an HTTP-status
error; a timeout is reported distinctly. Clarify in the bench --help/usage that
every hunter mode (stub included) needs the OBSIDIVM service, live/t3mp3st also
a key. Adds scripts/obsidivm-bridge.d.mts so the new test types cleanly.

Closes #156

* fix(obsidivm): distinguish invalid-URL from unreachable; test timeout path

Adversarial review of the diff found two gaps:
- A malformed OBSIDIVM_URL (ERR_INVALID_URL) was folded into the 'service
  unreachable — is it running?' path, misdirecting the user to start a service
  when the real fix is the URL. Now branched to a distinct 'Invalid OBSIDIVM
  base URL … set OBSIDIVM_URL' error, no err.unreachable flag.
- The timeout (AbortError) branch had no coverage. Added a test using a
  never-responding TCP server + short timeoutMs asserting 'timed out after Nms'
  and NOT unreachable; and a test for the invalid-URL branch.

Also completed the .d.mts stub with the cloudgoat* methods it was missing.

Refs #156
This commit is contained in:
Lyubomir Bozhinov authored and GitHub committed 2026-08-23 16:24:20 -04:00
1 parent c352f34843
commit 41f0c14039
4 files changed
+163 -8

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
/**
* Diagnostics guard for issue #156 — "Benchmark showing error" / `FATAL: fetch failed`.
*
* The obsidivm:* benchmarks are a thin HTTP client over the separate OBSIDIVM
* range service (default http://127.0.0.1:4200, see docs/OBSIDIVM.md). Every
* request routes through the bridge's `call()`. When the service is down, the
* raw `fetch` throws a bare `TypeError: fetch failed`, which surfaced to the user
* as an opaque `FATAL: fetch failed` naming neither the service, the URL, nor how
* to start it — even in `--hunter stub` mode, whose grading step still needs it.
*
* This pins that a connection failure becomes an ACTIONABLE error: it says the
* OBSIDIVM service is unreachable, names the base URL, and points at the escape
* hatches (docs/OBSIDIVM.md / OBSIDIVM_URL).
*/
import { describe, it, expect } from 'vitest';
import net from 'node:net';
import { obsidivm } from '../../scripts/obsidivm-bridge.mjs';
/**
* A guaranteed-closed localhost port: bind an ephemeral listener, capture its
* port, then close it — a connection there now yields ECONNREFUSED,
* deterministically and with no external network (hermetic, no mocking).
*/
function closedPort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.once('error', reject);
srv.listen(0, '127.0.0.1', () => {
const { port } = srv.address() as net.AddressInfo;
srv.close(() => resolve(port));
});
});
}
describe('obsidivm bridge — unreachable-service diagnostics (#156)', () => {
it('turns a connection failure into an actionable error, not a bare "fetch failed"', async () => {
const port = await closedPort();
const o = obsidivm({ baseUrl: `http://127.0.0.1:${port}`, timeoutMs: 2000 });
const err = (await o.getSpec().then(() => null, (e: unknown) => e)) as (Error & { unreachable?: boolean }) | null;
expect(err, 'getSpec should reject when the service is down').toBeTruthy();
// names the failure, the URL, and the self-service escape hatches
expect(err!.message).toMatch(/OBSIDIVM service unreachable/i);
expect(err!.message).toContain(`127.0.0.1:${port}`);
expect(err!.message).toMatch(/OBSIDIVM_URL|docs\/OBSIDIVM\.md/);
// and is flagged so callers can distinguish "down" from an HTTP-status error
expect(err!.unreachable).toBe(true);
});
it('reports a timeout distinctly (not as "unreachable")', async () => {
// A server that accepts the connection but never responds forces the
// AbortController timeout path — distinct from a refused connection.
const srv = net.createServer(() => {
/* accept, then hang: never write a response */
});
try {
const port: number = await new Promise((resolve) =>
srv.listen(0, '127.0.0.1', () => resolve((srv.address() as net.AddressInfo).port)),
);
const o = obsidivm({ baseUrl: `http://127.0.0.1:${port}`, timeoutMs: 200 });
const err = (await o.getSpec().then(() => null, (e: unknown) => e)) as (Error & { unreachable?: boolean }) | null;
expect(err, 'getSpec should reject on timeout').toBeTruthy();
expect(err!.message).toMatch(/timed out after \d+ms/i);
expect(err!.unreachable).toBeUndefined(); // a timeout is not "service down"
} finally {
// Fire-and-forget: the aborted request leaves a half-open socket, so
// awaiting close() would hang. Destroy connections and close without
// waiting — the assertions are already done. (closeAllConnections lands in
// newer @types/node than this repo pins, hence the cast.)
(srv as net.Server & { closeAllConnections?: () => void }).closeAllConnections?.();
srv.close();
}
});
it('reports a malformed base URL as a config error, not "unreachable"', async () => {
// ERR_INVALID_URL is the operator's mistake, not a down service — the message
// must point at OBSIDIVM_URL, not at starting the range service.
const o = obsidivm({ baseUrl: 'not-a-valid-url' });
const err = (await o.getSpec().then(() => null, (e: unknown) => e)) as (Error & { unreachable?: boolean }) | null;
expect(err, 'getSpec should reject on a bad URL').toBeTruthy();
expect(err!.message).toMatch(/invalid OBSIDIVM base URL/i);
expect(err!.message).toMatch(/OBSIDIVM_URL/);
expect(err!.message).not.toMatch(/unreachable|is it running/i);
expect(err!.unreachable).toBeUndefined();
});
});