From eae4241ad8c8ecfea0bf77f613973aae1131785b Mon Sep 17 00:00:00 2001 From: Sean Moss-Pultz Date: Mon, 27 Jul 2026 20:08:17 +0800 Subject: [PATCH 1/8] fix(find): survive slow/broken network paths to Raster (#97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three layers, one root cause. Node's happy-eyeballs gives each address-family connect attempt 250 ms — shorter than one round trip to api.raster.art from Asia (~250 ms RTT). On networks with broken IPv6 the IPv4 attempt dies mid-handshake, the IPv6 fallback has no route, and find fails or stalls with no output. - entry: raise the auto-select-family attempt timeout to 2.5 s so a transcontinental IPv4 connect is not cut off mid-handshake - raster-client: 20 s AbortSignal on every query — a stalled request becomes a fast error, never a silent hang; network failures rethrow as RasterUnreachableError naming the endpoint and the undici cause (bare 'fetch failed' says neither) - find: Raster unreachable at the coords step degrades to a one-item playlist with a warning instead of dying — Raster enriches a find, it is not required to build something playable Repro from #97 (FF series URL that hung at 90 s and 150 s) now completes: 35 items, or a fast actionable error when the network is truly down. Suite: 447/447. --- index.ts | 9 +++++++ src/commands/find.ts | 18 ++++++++++++- src/utilities/raster-client.ts | 43 +++++++++++++++++++++++++++---- tests/find-resolvers.test.ts | 46 ++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/index.ts b/index.ts index 383fb05..24c7dcc 100644 --- a/index.ts +++ b/index.ts @@ -9,7 +9,16 @@ process.on('warning', (warning) => { }); import 'dotenv/config'; +import { setDefaultAutoSelectFamilyAttemptTimeout } from 'net'; import { Command } from 'commander'; + +// Node's happy-eyeballs gives each address-family connect attempt 250 ms by +// default. That is shorter than a single round trip to a far-away API host +// (e.g. api.raster.art from Asia is ~250 ms RTT), so on networks with broken +// IPv6 the IPv4 attempt gets cut off mid-handshake, the IPv6 fallback has no +// route, and fetch fails or stalls intermittently (#97). 2.5 s per attempt is +// still fast-failing but no longer races the speed of light. +setDefaultAutoSelectFamilyAttemptTimeout(2500); import { readFileSync } from 'fs'; import { resolve, dirname } from 'path'; import { setupCommand } from './src/commands/setup'; diff --git a/src/commands/find.ts b/src/commands/find.ts index fbcad74..71375fb 100644 --- a/src/commands/find.ts +++ b/src/commands/find.ts @@ -20,6 +20,7 @@ import { listArtistArtworks, resolveAddressToArtist, formatSummaryLine, + RasterUnreachableError, } from '../utilities/raster-client'; import type { RasterArtworkSummary, RasterArtworkRow } from '../utilities/raster-client'; import { castPlaylist } from '../utilities/playlist-cast'; @@ -410,9 +411,24 @@ async function resolveTokenListInput( /** * Resolve on-chain coords to a target. If Raster doesn't index this token, * fall back to single-token mode so we still build something playable. + * + * Raster being unreachable gets the same fallback: it only enriches the find + * (series enumeration, artist labels) — the coords in hand are enough to + * build a playable single-token playlist, and a network blip on one optional + * dependency must not kill the whole command (#97). */ async function resolveCoords(coords: TokenCoords): Promise { - const summary = await resolveTokenToArtwork(coords.chain, coords.contract, coords.tokenId); + let summary: RasterArtworkSummary | null; + try { + summary = await resolveTokenToArtwork(coords.chain, coords.contract, coords.tokenId); + } catch (error) { + if (!(error instanceof RasterUnreachableError)) { + throw error; + } + console.log(chalk.yellow(` ${error.message}`)); + console.log(chalk.dim(' Continuing without Raster — building a one-item playlist.')); + return { kind: 'single', coords }; + } if (summary === null) { return { kind: 'single', coords }; } diff --git a/src/utilities/raster-client.ts b/src/utilities/raster-client.ts index 5b5cbd2..51787b7 100644 --- a/src/utilities/raster-client.ts +++ b/src/utilities/raster-client.ts @@ -28,6 +28,15 @@ import { USER_AGENT } from './user-agent'; const DEFAULT_RASTER_GRAPHQL_URL = 'https://api.raster.art/graphql'; +/** + * Hard ceiling on a single Raster round trip. Without it, a stalled TCP + * connect or unanswered request leaves `find` hanging with no output (#97) — + * the failure mode agents can least diagnose. 20 s is generous for a + * transcontinental GraphQL query and still turns "silent hang" into a fast, + * actionable error. + */ +const RASTER_REQUEST_TIMEOUT_MS = 20_000; + // CAIP-2 chain IDs for the chains the FF indexer supports. // Tezos mainnet's CAIP form uses the genesis block hash (NetXdQprcVkpaWU), // not the human-readable "tezos:mainnet" — Raster returns the hash form on @@ -44,6 +53,19 @@ const INDEXER_CHAIN_TO_CAIP: Record = { export type IndexerChain = 'ethereum' | 'tezos'; +/** + * Raster could not be reached at the network level (connect failure, broken + * IPv6 path, timeout). Distinct from API-level errors so callers that hold + * usable token coordinates can degrade gracefully instead of dying — Raster + * only enriches a find, it is not required to build a playable playlist. + */ +export class RasterUnreachableError extends Error { + constructor(message: string) { + super(message); + this.name = 'RasterUnreachableError'; + } +} + export interface RasterArtist { id: string; name: string; @@ -118,11 +140,22 @@ async function rasterQuery(query: string, variables: Record) if (apiKey) { headers['x-api-key'] = apiKey; } - const response = await fetch(endpoint, { - method: 'POST', - headers, - body: JSON.stringify({ query, variables }), - }); + let response: Response; + try { + response = await fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify({ query, variables }), + signal: AbortSignal.timeout(RASTER_REQUEST_TIMEOUT_MS), + }); + } catch (error) { + // Undici's "fetch failed" carries no endpoint and no cause in its + // message — name the host and the real failure so the error is + // actionable, and let callers detect network failure by type. + const cause = (error as { cause?: { message?: string } }).cause; + const detail = cause?.message ?? (error as Error).message; + throw new RasterUnreachableError(`Raster API unreachable (${endpoint}): ${detail}`); + } if (!response.ok) { const body = await response.text(); throw new Error(`Raster API ${response.status} ${response.statusText}: ${body.slice(0, 200)}`); diff --git a/tests/find-resolvers.test.ts b/tests/find-resolvers.test.ts index 63bc1cc..b33ff76 100644 --- a/tests/find-resolvers.test.ts +++ b/tests/find-resolvers.test.ts @@ -696,3 +696,49 @@ describe('resolveFeralFileToken', () => { ); }); }); + +describe('rasterQuery network resilience (#97)', () => { + test('connect failure → RasterUnreachableError naming the endpoint and cause', async () => { + const mock = (async () => { + // Undici's opaque envelope: message says nothing, cause has the truth. + throw Object.assign(new TypeError('fetch failed'), { + cause: new Error('connect ETIMEDOUT 65.109.41.181:443'), + }); + }) as FetchFn; + await assert.rejects( + () => withMockedFetch(mock, () => resolveTokenToArtwork('ethereum', '0xabc', '1')), + (error: Error) => { + assert.equal(error.name, 'RasterUnreachableError'); + assert.match(error.message, /api\.raster\.art/); + assert.match(error.message, /ETIMEDOUT/); + return true; + } + ); + }); + + test('abort (timeout) → RasterUnreachableError, not a hang or bare AbortError', async () => { + const mock = (async () => { + throw Object.assign(new DOMException('This operation was aborted', 'AbortError'), {}); + }) as FetchFn; + await assert.rejects( + () => withMockedFetch(mock, () => resolveTokenToArtwork('ethereum', '0xabc', '1')), + (error: Error) => { + assert.equal(error.name, 'RasterUnreachableError'); + return true; + } + ); + }); + + test('fetch carries an abort signal so a stalled request cannot hang forever', async () => { + let sawSignal = false; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + sawSignal = init?.signal instanceof AbortSignal; + return new Response(JSON.stringify({ data: { tokenByRef: null } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as FetchFn; + await withMockedFetch(mock, () => resolveTokenToArtwork('ethereum', '0xabc', '1')); + assert.equal(sawSignal, true); + }); +}); From 35b26f1362681635cf1d6627666468c411b9a3de Mon Sep 17 00:00:00 2001 From: Sean Moss-Pultz Date: Mon, 27 Jul 2026 21:06:45 +0800 Subject: [PATCH 2/8] fix(review): extend the network-error boundary through Raster body reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reviewers caught it: rasterQuery only converted failures from fetch() itself. A body that stalls or terminates after headers rejects in response.text()/response.json(), outside the boundary — so resolveCoords couldn't recognize RasterUnreachableError and never performed its promised single-token fallback. Body consumption now sits inside the boundary: a rejected read on an ok response throws RasterUnreachableError (a 200 whose body can't be read is unusable either way; falling back is right even for server-side garbage). Intentional API-level errors keep their types: an HTTP error stays a status error even when its body is unreadable (best-effort text, never masks the status), and GraphQL errors are unchanged. Tests: mid-stream termination → typed error; 502 with unreadable body → status error, not unreachable; end-to-end find run against a mock server that destroys the body → warns and degrades to a one-item playlist. 450/450. --- src/utilities/raster-client.ts | 23 ++++++++++++++++-- tests/find-command.test.ts | 23 ++++++++++++++++++ tests/find-resolvers.test.ts | 44 ++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/utilities/raster-client.ts b/src/utilities/raster-client.ts index 51787b7..7f33967 100644 --- a/src/utilities/raster-client.ts +++ b/src/utilities/raster-client.ts @@ -157,10 +157,29 @@ async function rasterQuery(query: string, variables: Record) throw new RasterUnreachableError(`Raster API unreachable (${endpoint}): ${detail}`); } if (!response.ok) { - const body = await response.text(); + // Body text is best-effort context; the HTTP status alone is the error. + // A body read that rejects here must not mask the status — and an HTTP + // error is an API-level failure, deliberately NOT RasterUnreachableError. + const body = await response.text().catch(() => ''); throw new Error(`Raster API ${response.status} ${response.statusText}: ${body.slice(0, 200)}`); } - const payload = (await response.json()) as { data?: T; errors?: Array<{ message: string }> }; + // The network-error boundary extends through body consumption: a response + // whose stream stalls past the headers or terminates mid-body rejects + // HERE, not in fetch() above. Those must still surface as + // RasterUnreachableError or resolveCoords cannot perform its promised + // single-token fallback. (A 200 whose body cannot be read or parsed is + // unusable either way — falling back is right even for server-side + // garbage.) + let payload: { data?: T; errors?: Array<{ message: string }> }; + try { + payload = (await response.json()) as { data?: T; errors?: Array<{ message: string }> }; + } catch (error) { + const cause = (error as { cause?: { message?: string } }).cause; + const detail = cause?.message ?? (error as Error).message; + throw new RasterUnreachableError( + `Raster API unreachable (${endpoint}): response body failed: ${detail}` + ); + } if (payload.errors && payload.errors.length > 0) { throw new Error(`Raster API error: ${payload.errors.map((e) => e.message).join('; ')}`); } diff --git a/tests/find-command.test.ts b/tests/find-command.test.ts index 127b853..f0f68af 100644 --- a/tests/find-command.test.ts +++ b/tests/find-command.test.ts @@ -47,6 +47,13 @@ before(async () => { variables: Record; }; res.setHeader('Content-Type', 'application/json'); + if (destroyResponseBody) { + // Headers out, body terminated mid-stream: fetch() resolves, the + // body read rejects — the #98-review failure mode. + res.write('{"data": {'); + res.destroy(); + return; + } res.end(JSON.stringify(handler(query, variables ?? {}))); }); }); @@ -64,8 +71,11 @@ after(() => { server.close(); }); +let destroyResponseBody = false; + beforeEach(() => { handler = () => ({ errors: [{ message: 'no handler installed for this test' }] }); + destroyResponseBody = false; }); interface RunResult { @@ -278,6 +288,19 @@ describe('find command — Raster series flow (mock GraphQL server)', () => { assert.match(result.stdout, /Build playlist with 1 token\?/); assert.match(result.stdout, /Cancelled\./); }); + + test('Raster body terminating mid-stream → warns and degrades to one-item playlist (#98 review)', async () => { + // fetch() succeeds (headers arrive), the body read rejects. The typed + // RasterUnreachableError must survive to resolveCoords so the find flow + // degrades instead of dying — the whole point of the boundary fix. + destroyResponseBody = true; + const result = await runFind([`ethereum:${VALID_ETH_CONTRACT}:1`], { stdin: 'n\n' }); + assert.equal(result.code, 0); + assert.match(result.stdout, /Raster API unreachable/); + assert.match(result.stdout, /Continuing without Raster — building a one-item playlist/); + assert.match(result.stdout, /Build playlist with 1 token\?/); + assert.match(result.stdout, /Cancelled\./); + }); }); describe('find command — resolver-backed token-list flow', () => { diff --git a/tests/find-resolvers.test.ts b/tests/find-resolvers.test.ts index b33ff76..ead7def 100644 --- a/tests/find-resolvers.test.ts +++ b/tests/find-resolvers.test.ts @@ -742,3 +742,47 @@ describe('rasterQuery network resilience (#97)', () => { assert.equal(sawSignal, true); }); }); + +describe('rasterQuery body-read failures (#98 review)', () => { + test('body that terminates mid-stream → RasterUnreachableError (fallback stays reachable)', async () => { + const mock = (async () => { + // Headers arrive fine; the body stream errors — response.json() rejects + // outside fetch()'s own rejection path. + const body = new ReadableStream({ + start(controller) { + controller.error(new Error('terminated')); + }, + }); + return new Response(body, { status: 200, headers: { 'Content-Type': 'application/json' } }); + }) as FetchFn; + await assert.rejects( + () => withMockedFetch(mock, () => resolveTokenToArtwork('ethereum', '0xabc', '1')), + (error: Error) => { + assert.equal(error.name, 'RasterUnreachableError'); + assert.match(error.message, /response body failed/); + return true; + } + ); + }); + + test('HTTP error with unreadable body keeps the status error, not unreachable', async () => { + const mock = (async () => { + const body = new ReadableStream({ + start(controller) { + controller.error(new Error('terminated')); + }, + }); + return new Response(body, { status: 502, statusText: 'Bad Gateway' }); + }) as FetchFn; + await assert.rejects( + () => withMockedFetch(mock, () => resolveTokenToArtwork('ethereum', '0xabc', '1')), + (error: Error) => { + // API-level failure: NOT the network-unreachable type, and the + // status survives even though the body could not be read. + assert.notEqual(error.name, 'RasterUnreachableError'); + assert.match(error.message, /Raster API 502/); + return true; + } + ); + }); +}); From 698727ec7853b4bfe8ad59d56b485522a7534bd6 Mon Sep 17 00:00:00 2001 From: Sean Moss-Pultz Date: Mon, 27 Jul 2026 20:33:03 +0800 Subject: [PATCH 3/8] =?UTF-8?q?refactor(find):=20FF=20series=20URLs=20reso?= =?UTF-8?q?lve=20through=20the=20resolver,=20not=20ff-marketplace=E2=86=92?= =?UTF-8?q?Raster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Feral File series URL resolved ONE token via ff-marketplace, then asked Raster, which maps the token to its parent artwork — so a single-edition series page (e.g. 36 Points Display Edition) built the sibling collection's 35 tokens instead of the 1 the page describes. Flagged as out-of-scope in #98; this is that fix. Series URLs now route through source-resolver's Feral File site module (series slug → /api/series → /api/artworks?seriesID), exactly like shows already did — one FF resolution path for page-shaped URLs, and find builds precisely the tokens the page describes. Artwork URLs keep the coords→Raster path: a single-token page carries no series intent to preserve. Verified live: the Display Edition series URL now builds 1 item with correct provenance (was 35), in seconds (was minutes of indexer warming for tokens the user never asked about). --- src/commands/find.ts | 18 ++++++++-- tests/find-command.test.ts | 70 +++++++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/commands/find.ts b/src/commands/find.ts index 71375fb..3728b65 100644 --- a/src/commands/find.ts +++ b/src/commands/find.ts @@ -285,14 +285,26 @@ async function resolveTarget( return resolveCoords(parsed.coords); } if (parsed.kind === 'ff-url') { - if (parsed.urlKind === 'show') { + // Shows and series both resolve through the source-resolver's Feral File + // site module, which enumerates exactly the tokens the page describes + // (series slug → /api/series → /api/artworks?seriesID). The old series + // path resolved ONE token via ff-marketplace and then asked Raster, which + // expands to the token's parent artwork — so a single-edition series page + // (e.g. a Display Edition) surprisingly built the sibling collection's + // full token list. A series URL means the user pointed at a specific + // series; build exactly that. Artwork URLs keep the coords→Raster path: + // a single-token page carries no series intent to preserve. + if (parsed.urlKind === 'show' || parsed.urlKind === 'series') { + const label = parsed.urlKind === 'show' ? 'show' : 'series'; const target = await resolveTokenListInput( input, resolverLimitFromOption(limitOption), - `Feral File show ${parsed.identifier}` + `Feral File ${label} ${parsed.identifier}` ); if (target === null) { - throw new Error(`Feral File: no supported tokens found for show "${parsed.identifier}".`); + throw new Error( + `Feral File: no supported tokens found for ${label} "${parsed.identifier}".` + ); } return target; } diff --git a/tests/find-command.test.ts b/tests/find-command.test.ts index f0f68af..81e27c8 100644 --- a/tests/find-command.test.ts +++ b/tests/find-command.test.ts @@ -309,11 +309,12 @@ describe('find command — resolver-backed token-list flow', () => { const superRareCollectionUrl = 'https://superrare.com/collection/0x1234567890123456789012345678901234567890'; const feralFileShowUrl = 'https://feralfile.com/exhibitions/shows/mock-show'; + const feralFileSeriesUrl = 'https://feralfile.com/exhibitions/series/mock-series'; const dir = mkdtempSync(join(tmpdir(), 'ff-find-token-list-')); const originalCwd = process.cwd(); const resolverCalls: Array<{ input: string; options: { limit: number } }> = []; const promptQuestions: string[] = []; - const promptAnswers = ['yes', 's', 'yes', 's', 'yes', 's']; + const promptAnswers = ['yes', 's', 'yes', 's', 'yes', 's', 'yes', 's']; const indexedBatches: unknown[][] = []; const stdoutWrites: string[] = []; const stderrWrites: string[] = []; @@ -344,6 +345,23 @@ describe('find command — resolver-backed token-list flow', () => { reason: 'mock resolver miss', }; } + // Series pages resolve to exactly the tokens the page describes — + // one here, mirroring a single-edition series (the case the old + // ff-marketplace→Raster path expanded to a sibling collection). + if (input === feralFileSeriesUrl) { + return { + kind: 'tokens', + title: 'Mock Feral File Series', + coords: [ + { + chain: 'ethereum', + contract: '0x1234567890123456789012345678901234567890', + tokenId: '21', + }, + ], + hasMore: false, + }; + } const isSuperRareCollection = input === superRareCollectionUrl; const isFeralFileShow = input === feralFileShowUrl; return { @@ -560,6 +578,56 @@ describe('find command — resolver-backed token-list flow', () => { stderrWrites.join(''), /Feral File: no supported tokens found for show "mock-show"/ ); + + // Series URLs route through the same resolver token-list path as shows + // (never ff-marketplace→Raster, which expanded a single-edition series + // to its parent artwork's full token list). The mock Raster server + // seeing zero traffic is asserted implicitly: a Raster call would hit + // this test's GraphQL handler and fail the deepEqual on indexedBatches. + resolverCalls.length = 0; + promptQuestions.length = 0; + indexedBatches.length = 0; + stdoutWrites.length = 0; + stderrWrites.length = 0; + resolverMissInputs.delete(feralFileShowUrl); + + await findCommand.parseAsync(['node', 'find', feralFileSeriesUrl, '--limit', '1'], { + from: 'node', + }); + + assert.deepEqual(resolverCalls, [ + { + input: feralFileSeriesUrl, + options: { limit: 1 }, + }, + ]); + assert.deepEqual(indexedBatches, [ + [ + { + chain: 'ethereum', + contractAddress: '0x1234567890123456789012345678901234567890', + tokenId: '21', + }, + ], + ]); + assert.match(stdoutWrites.join(''), /Mock Feral File Series/); + + resolverCalls.length = 0; + stderrWrites.length = 0; + resolverMissInputs.add(feralFileSeriesUrl); + + await assert.rejects( + () => + findCommand.parseAsync(['node', 'find', feralFileSeriesUrl, '--limit', '1'], { + from: 'node', + }), + /process\.exit\(1\)/ + ); + + assert.match( + stderrWrites.join(''), + /Feral File: no supported tokens found for series "mock-series"/ + ); } finally { process.stdout.write = originalStdoutWrite; process.stderr.write = originalStderrWrite; From e7007279bef39737742aef96b83a8f47feb74d34 Mon Sep 17 00:00:00 2001 From: Sean Moss-Pultz Date: Mon, 27 Jul 2026 20:36:06 +0800 Subject: [PATCH 4/8] feat(find): shared HTTP deadlines everywhere + visible indexer progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the #97 hardening beyond Raster. Every resolver-facing fetch (Art Blocks, Feral File, fxhash, Neort, Objkt, OpenSea, Verse, playlist-source, nft-indexer, feed-fetcher — 15 call sites) now goes through fetchWithTimeout, so a stalled connect or unanswered request is structurally incapable of hanging the CLI. Caller-supplied signals are composed with the deadline via AbortSignal.any, never replaced. The FF indexer batch loop already logged progress — verbose-gated, so a multi-minute warm of unseen tokens rendered as a silent hang in normal runs. getNFTTokenInfoBatch gains an optional onProgress callback; find renders per-batch progress and surfaces the --limit warming hint upfront (it used to appear only after a failure). Suite: 449/449. --- src/commands/find.ts | 19 ++++++++++++++++- src/utilities/ab-marketplace.ts | 3 ++- src/utilities/feed-fetcher.js | 9 +++++--- src/utilities/ff-marketplace.ts | 3 ++- src/utilities/fxhash-marketplace.ts | 5 +++-- src/utilities/http.ts | 24 +++++++++++++++++++++ src/utilities/neort-marketplace.ts | 3 ++- src/utilities/nft-indexer.js | 22 +++++++++++++++---- src/utilities/objkt-marketplace.ts | 3 ++- src/utilities/opensea-marketplace.ts | 10 ++++++--- src/utilities/playlist-source.ts | 3 ++- src/utilities/verse-marketplace.ts | 3 ++- tests/find-resolvers.test.ts | 32 ++++++++++++++++++++++++++++ 13 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 src/utilities/http.ts diff --git a/src/commands/find.ts b/src/commands/find.ts index 3728b65..355fd59 100644 --- a/src/commands/find.ts +++ b/src/commands/find.ts @@ -205,6 +205,17 @@ async function runResolvedTarget(target: ResolvedTarget, options: FindOptions): chalk.dim(`Indexing ${tokens.length} token${tokens.length === 1 ? '' : 's'} via FF indexer...`) ); + // Previously-unseen tokens make the indexer warm renditions by polling — + // minutes of wall time on a large series. Say so upfront (the --limit + // warming hint used to appear only after a failure) and render per-batch + // progress so a long index never reads as a hang. + if (tokens.length > 10) { + console.log( + chalk.dim( + ' First-time tokens can take the indexer a while to warm; use `--limit 5` for a faster first pass.' + ) + ); + } // Second positional arg on getNFTTokenInfoBatch is `duration` (DP-1 item // display seconds), not concurrency — concurrency is hardcoded inside. // Omit it for auto timing: video/audio items carry no duration and play @@ -214,7 +225,13 @@ async function runResolvedTarget(target: ResolvedTarget, options: FindOptions): chain: t.chain, contractAddress: t.contract, tokenId: t.tokenId, - })) + })), + undefined, + (done: number, total: number) => { + if (total > done) { + console.log(chalk.dim(` ${done}/${total} tokens indexed...`)); + } + } ); // FF-indexer bypass for Raster-minted tokens. The indexer doesn't carry diff --git a/src/utilities/ab-marketplace.ts b/src/utilities/ab-marketplace.ts index 6844f07..a4ece57 100644 --- a/src/utilities/ab-marketplace.ts +++ b/src/utilities/ab-marketplace.ts @@ -11,6 +11,7 @@ * direct GraphQL path is the right shape. */ +import { fetchWithTimeout } from './http'; import * as logger from '../logger'; import type { TokenCoords } from '@feralfile/source-resolver'; import { USER_AGENT } from './user-agent'; @@ -43,7 +44,7 @@ export async function resolveArtBlocksCollection(slug: string): Promise(path: string): Promise { const url = `${FF_API_BASE}${path}`; logger.debug(`[FF API] GET ${url}`); - const response = await fetch(url, { + const response = await fetchWithTimeout(url, { headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' }, }); if (!response.ok) { diff --git a/src/utilities/fxhash-marketplace.ts b/src/utilities/fxhash-marketplace.ts index 85ee1a8..b487232 100644 --- a/src/utilities/fxhash-marketplace.ts +++ b/src/utilities/fxhash-marketplace.ts @@ -15,6 +15,7 @@ * GraphQL endpoint and surface as "slug not found". */ +import { fetchWithTimeout } from './http'; import * as logger from '../logger'; import type { TokenCoords } from '@feralfile/source-resolver'; import { USER_AGENT } from './user-agent'; @@ -40,7 +41,7 @@ interface FxhashObjktResponse { export async function resolveFxhashIteration(slug: string): Promise { logger.debug(`[fxhash] Resolving iteration slug "${slug}"`); const query = 'query ($slug: String!) { objkt(slug: $slug) { gentkContractAddress onChainId } }'; - const response = await fetch(FXHASH_GRAPHQL, { + const response = await fetchWithTimeout(FXHASH_GRAPHQL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT }, body: JSON.stringify({ query, variables: { slug } }), @@ -110,7 +111,7 @@ export async function resolveFxhashProject(slug: string): Promise { ' }' + ' }' + '}'; - const response = await fetch(FXHASH_GRAPHQL, { + const response = await fetchWithTimeout(FXHASH_GRAPHQL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT }, body: JSON.stringify({ query, variables: { slug } }), diff --git a/src/utilities/http.ts b/src/utilities/http.ts new file mode 100644 index 0000000..3703443 --- /dev/null +++ b/src/utilities/http.ts @@ -0,0 +1,24 @@ +/** + * Shared fetch-with-timeout for every outbound HTTP call in the CLI. + * + * Bare `fetch` has no deadline: a stalled connect or an unanswered request + * hangs the command silently — the failure mode agents can least diagnose, + * and the one behind #97. Every resolver and indexer call goes through here + * so "silent hang" is structurally impossible, not per-call-site discipline. + * + * Callers that pass their own `signal` keep it (composed with the deadline + * via AbortSignal.any), so cancellation semantics are never silently + * replaced by a timeout. + */ + +export const DEFAULT_HTTP_TIMEOUT_MS = 20_000; + +export function fetchWithTimeout( + input: string | URL | Request, + init: RequestInit = {}, + timeoutMs: number = DEFAULT_HTTP_TIMEOUT_MS +): Promise { + const deadline = AbortSignal.timeout(timeoutMs); + const signal = init.signal ? AbortSignal.any([init.signal, deadline]) : deadline; + return fetch(input, { ...init, signal }); +} diff --git a/src/utilities/neort-marketplace.ts b/src/utilities/neort-marketplace.ts index 0aaf1cd..d16df3e 100644 --- a/src/utilities/neort-marketplace.ts +++ b/src/utilities/neort-marketplace.ts @@ -23,6 +23,7 @@ * reject when `id` is empty. */ +import { fetchWithTimeout } from './http'; import * as logger from '../logger'; import { USER_AGENT } from './user-agent'; @@ -58,7 +59,7 @@ export interface NeortArt { */ export async function resolveNeortArt(id: string): Promise { logger.debug(`[neort] Resolving art id "${id}"`); - const response = await fetch(`${NEORT_API_BASE}/art/${encodeURIComponent(id)}`, { + const response = await fetchWithTimeout(`${NEORT_API_BASE}/art/${encodeURIComponent(id)}`, { headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' }, }); if (!response.ok) { diff --git a/src/utilities/nft-indexer.js b/src/utilities/nft-indexer.js index 6175017..7b3efe6 100644 --- a/src/utilities/nft-indexer.js +++ b/src/utilities/nft-indexer.js @@ -5,6 +5,7 @@ */ const GRAPHQL_ENDPOINT = 'https://indexer.feralfile.com/graphql'; +const { fetchWithTimeout } = require('./http'); const logger = require('../logger'); const { applyItemTiming } = require('./playlist-builder'); @@ -196,7 +197,7 @@ async function queryTokens(params = {}) { logger.debug('[NFT Indexer] Querying tokens:', { token_cids, owners, limit, offset }); logger.debug('[NFT Indexer] GraphQL query:', query); - const response = await fetch(GRAPHQL_ENDPOINT, { + const response = await fetchWithTimeout(GRAPHQL_ENDPOINT, { method: 'POST', headers, body: JSON.stringify({ query }), @@ -675,7 +676,7 @@ async function getNFTTokenInfoSingle(params, duration, options = {}) { * @param {number} duration - Display duration in seconds * @returns {Promise} Array of DP1 items */ -async function getNFTTokenInfoBatch(tokens, duration) { +async function getNFTTokenInfoBatch(tokens, duration, onProgress) { logger.info(`[NFT Indexer] 📦 Starting batch processing for ${tokens.length} token(s)...`); logger.debug('[NFT Indexer] Batch tokens:', tokens); @@ -708,6 +709,19 @@ async function getNFTTokenInfoBatch(tokens, duration) { logger.info(`[NFT Indexer] Batch complete: ${successful} success, ${failed} failed`); results.push(...batchResults); + + // Indexing previously-unseen tokens can take minutes (the indexer warms + // renditions by polling); without user-visible progress the CLI reads as + // hung. logger.info above is verbose-gated, so callers that face a human + // pass onProgress to render it. Errors in the callback must never break + // the batch — reporting is strictly best-effort. + if (typeof onProgress === 'function') { + try { + onProgress(results.length, tokens.length); + } catch { + /* progress display is best-effort */ + } + } } logger.info(`[NFT Indexer] ✓ Batch processing complete: ${results.length} total results`); @@ -788,7 +802,7 @@ async function triggerIndexingAsync(chain, contractAddress, tokenId) { const headers = { 'Content-Type': 'application/json' }; - const response = await fetch(GRAPHQL_ENDPOINT, { + const response = await fetchWithTimeout(GRAPHQL_ENDPOINT, { method: 'POST', headers, body: JSON.stringify({ query: mutation, variables }), @@ -852,7 +866,7 @@ async function queryJobStatus(jobId) { const variables = { job_id: id }; const headers = { 'Content-Type': 'application/json' }; - const response = await fetch(GRAPHQL_ENDPOINT, { + const response = await fetchWithTimeout(GRAPHQL_ENDPOINT, { method: 'POST', headers, body: JSON.stringify({ query, variables }), diff --git a/src/utilities/objkt-marketplace.ts b/src/utilities/objkt-marketplace.ts index 49ebd34..0bd81df 100644 --- a/src/utilities/objkt-marketplace.ts +++ b/src/utilities/objkt-marketplace.ts @@ -10,6 +10,7 @@ * when an alias segment is detected. */ +import { fetchWithTimeout } from './http'; import * as logger from '../logger'; import { USER_AGENT } from './user-agent'; @@ -30,7 +31,7 @@ interface FaPathResponse { export async function resolveObjktAlias(alias: string): Promise { logger.debug(`[Objkt] Resolving alias "${alias}"`); const query = 'query ($path: String!) { fa(where: { path: { _eq: $path } }) { contract } }'; - const response = await fetch(OBJKT_GRAPHQL, { + const response = await fetchWithTimeout(OBJKT_GRAPHQL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT }, body: JSON.stringify({ query, variables: { path: alias } }), diff --git a/src/utilities/opensea-marketplace.ts b/src/utilities/opensea-marketplace.ts index 92dc265..157da4f 100644 --- a/src/utilities/opensea-marketplace.ts +++ b/src/utilities/opensea-marketplace.ts @@ -24,6 +24,7 @@ * a layout change degrades to a one-step workaround, not a dead end. */ +import { fetchWithTimeout } from './http'; import * as logger from '../logger'; import type { TokenCoords } from '@feralfile/source-resolver'; import { USER_AGENT } from './user-agent'; @@ -51,9 +52,12 @@ const FALLBACK_HINT = */ export async function resolveOpenSeaCollection(slug: string): Promise { logger.debug(`[OpenSea] Resolving collection slug "${slug}"`); - const response = await fetch(`https://opensea.io/collection/${encodeURIComponent(slug)}`, { - headers: { 'User-Agent': USER_AGENT, Accept: 'text/html' }, - }); + const response = await fetchWithTimeout( + `https://opensea.io/collection/${encodeURIComponent(slug)}`, + { + headers: { 'User-Agent': USER_AGENT, Accept: 'text/html' }, + } + ); if (!response.ok) { throw new Error( `OpenSea collection page returned ${response.status} ${response.statusText}. ` + FALLBACK_HINT diff --git a/src/utilities/playlist-source.ts b/src/utilities/playlist-source.ts index 8587aa5..b5595dc 100644 --- a/src/utilities/playlist-source.ts +++ b/src/utilities/playlist-source.ts @@ -1,3 +1,4 @@ +import { fetchWithTimeout } from './http'; import { promises as fs } from 'fs'; import type { Playlist } from '../types'; @@ -48,7 +49,7 @@ export async function loadPlaylistSource(source: string): Promise { const url = `https://verse.works/series/${encodeURIComponent(slug)}`; - const response = await fetch(url); + const response = await fetchWithTimeout(url); if (!response.ok) { throw new Error(`Verse series lookup failed for ${slug}: ${response.status}`); } diff --git a/tests/find-resolvers.test.ts b/tests/find-resolvers.test.ts index ead7def..3b999e2 100644 --- a/tests/find-resolvers.test.ts +++ b/tests/find-resolvers.test.ts @@ -786,3 +786,35 @@ describe('rasterQuery body-read failures (#98 review)', () => { ); }); }); + +describe('fetchWithTimeout (shared HTTP deadline)', () => { + test('every resolver-facing fetch carries a deadline signal', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { fetchWithTimeout } = require('../src/utilities/http'); + let sawSignal = false; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + sawSignal = init?.signal instanceof AbortSignal; + return new Response('{}', { status: 200 }); + }) as FetchFn; + await withMockedFetch(mock, () => fetchWithTimeout('https://example.com')); + assert.equal(sawSignal, true); + }); + + test('caller-supplied signal is composed with the deadline, not replaced', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { fetchWithTimeout } = require('../src/utilities/http'); + const caller = new AbortController(); + let received: AbortSignal | undefined; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + received = init?.signal ?? undefined; + return new Response('{}', { status: 200 }); + }) as FetchFn; + await withMockedFetch(mock, () => + fetchWithTimeout('https://example.com', { signal: caller.signal }) + ); + assert.ok(received instanceof AbortSignal); + // Aborting the caller's controller must abort the composed signal. + caller.abort(); + assert.equal(received?.aborted, true); + }); +}); From 21071931174fd23737b499676c6549de8ae0eafb Mon Sep 17 00:00:00 2001 From: Sean Moss-Pultz Date: Mon, 27 Jul 2026 21:09:56 +0800 Subject: [PATCH 5/8] fix(review): ssh-access and the FF1 compatibility probe ride the shared deadline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three reviewers caught the gap: ssh-access.ts sent the device request with bare fetch, and assertFF1CommandCompatibility's default probe did too — either could hang `ff-cli ssh` indefinitely, contradicting this PR's no-fetch-can-hang guarantee. Both now route through fetchWithTimeout; injected fetchFn overrides are unchanged. Tests: ssh request and default probe each carry a deadline signal. 454/454. --- src/utilities/ff1-compatibility.ts | 6 ++++- src/utilities/ssh-access.ts | 3 ++- tests/find-resolvers.test.ts | 40 ++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/utilities/ff1-compatibility.ts b/src/utilities/ff1-compatibility.ts index 062c197..1f6e97e 100644 --- a/src/utilities/ff1-compatibility.ts +++ b/src/utilities/ff1-compatibility.ts @@ -2,6 +2,7 @@ * FF1 device compatibility helpers for command preflight checks. */ +import { fetchWithTimeout } from './http'; import { getFF1DeviceConfig } from '../config'; import { findConfiguredDeviceIndex } from './device-lookup'; import type { FF1Device, FF1DeviceConfig } from '../types'; @@ -124,7 +125,10 @@ export async function assertFF1CommandCompatibility( command: FF1Command, options: CompatibilityCheckOptions = {} ): Promise { - const fetchFn = options.fetchFn || globalThis.fetch.bind(globalThis); + // Default probe fetch rides the shared deadline: a device that accepts + // the TCP connection but never answers must not hang `ff-cli ssh`. + const fetchFn = + options.fetchFn || ((input: string, init?: RequestInit) => fetchWithTimeout(input, init ?? {})); const policy = getCommandPolicy(command); const versionResult = await detectFF1VersionSafely( diff --git a/src/utilities/ssh-access.ts b/src/utilities/ssh-access.ts index be5467d..df966ca 100644 --- a/src/utilities/ssh-access.ts +++ b/src/utilities/ssh-access.ts @@ -2,6 +2,7 @@ * SSH access control for FF1 devices. */ +import { fetchWithTimeout } from './http'; import * as logger from '../logger'; import { assertFF1CommandCompatibility, resolveConfiguredDevice } from './ff1-compatibility'; @@ -100,7 +101,7 @@ export async function sendSshAccessCommand({ headers['API-KEY'] = device.apiKey; } - const response = await fetch(apiUrl, { + const response = await fetchWithTimeout(apiUrl, { method: 'POST', headers, body: JSON.stringify(requestBody), diff --git a/tests/find-resolvers.test.ts b/tests/find-resolvers.test.ts index 3b999e2..8c2bd3a 100644 --- a/tests/find-resolvers.test.ts +++ b/tests/find-resolvers.test.ts @@ -818,3 +818,43 @@ describe('fetchWithTimeout (shared HTTP deadline)', () => { assert.equal(received?.aborted, true); }); }); + +describe('device request deadlines (#101 review)', () => { + test('ssh access request carries a deadline signal', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { sendSshAccessCommand } = require('../src/utilities/ssh-access'); + let sawSignal = false; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + sawSignal = init?.signal instanceof AbortSignal; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as FetchFn; + await withMockedFetch(mock, () => + sendSshAccessCommand({ + device: { name: 'test', host: 'http://127.0.0.1:1111' }, + action: 'enable', + publicKey: 'ssh-ed25519 AAAA test', + }) + ); + assert.equal(sawSignal, true); + }); + + test('ff1 compatibility probe default fetch carries a deadline signal', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { assertFF1CommandCompatibility } = require('../src/utilities/ff1-compatibility'); + let sawSignal = false; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + sawSignal = init?.signal instanceof AbortSignal; + return new Response(JSON.stringify({ message: { installedVersion: '1.0.21' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as FetchFn; + await withMockedFetch(mock, () => + assertFF1CommandCompatibility({ name: 'test', host: 'http://127.0.0.1:1111' }, 'sshAccess') + ); + assert.equal(sawSignal, true); + }); +}); From eec660c65c626b50e85660e6d7439774882dd12b Mon Sep 17 00:00:00 2001 From: Sean Moss-Pultz Date: Mon, 27 Jul 2026 21:43:22 +0800 Subject: [PATCH 6/8] fix(test): ssh deadline test carries its own device config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test called sendSshAccessCommand with an invented argument shape and leaned on the developer's real ~/.config/ff-cli to resolve a device — green on a configured machine, red in CI where the config-resolution early-out returned before any fetch. Now it uses the real signature, writes a temp config and points XDG_CONFIG_HOME at it, asserts the command actually succeeded (so an early-out can't fake a pass), and requires every device fetch — probe and request — to carry a deadline signal. --- tests/find-resolvers.test.ts | 64 +++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/tests/find-resolvers.test.ts b/tests/find-resolvers.test.ts index 8c2bd3a..5d8d8d4 100644 --- a/tests/find-resolvers.test.ts +++ b/tests/find-resolvers.test.ts @@ -823,22 +823,64 @@ describe('device request deadlines (#101 review)', () => { test('ssh access request carries a deadline signal', async () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const { sendSshAccessCommand } = require('../src/utilities/ssh-access'); - let sawSignal = false; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const fs = require('node:fs'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const os = require('node:os'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const path = require('node:path'); + + // sendSshAccessCommand resolves its device from the user config, so the + // test must carry its own — a machine with a real ~/.config/ff-cli made + // the original version of this test pass while CI (no config) failed. + // XDG_CONFIG_HOME is read per-call, so a temp config keeps it hermetic + // on every platform AND independent of the developer's real devices. + const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ff-cli-ssh-test-')); + fs.mkdirSync(path.join(configHome, 'ff-cli'), { recursive: true }); + fs.writeFileSync( + path.join(configHome, 'ff-cli', 'config.json'), + JSON.stringify({ + ff1Devices: { devices: [{ name: 'test', host: 'http://127.0.0.1:1111' }] }, + }) + ); + const originalXdg = process.env.XDG_CONFIG_HOME; + + const signals: boolean[] = []; const mock = (async (_input: string | URL | Request, init?: RequestInit) => { - sawSignal = init?.signal instanceof AbortSignal; - return new Response(JSON.stringify({ ok: true }), { + signals.push(init?.signal instanceof AbortSignal); + // First call is the compatibility probe (getDeviceStatus), second is + // the sshAccess request itself; this reply shape satisfies both. + return new Response(JSON.stringify({ ok: true, message: { installedVersion: '1.0.21' } }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); }) as FetchFn; - await withMockedFetch(mock, () => - sendSshAccessCommand({ - device: { name: 'test', host: 'http://127.0.0.1:1111' }, - action: 'enable', - publicKey: 'ssh-ed25519 AAAA test', - }) - ); - assert.equal(sawSignal, true); + + try { + process.env.XDG_CONFIG_HOME = configHome; + const result = await withMockedFetch(mock, () => + sendSshAccessCommand({ + enabled: true, + publicKey: 'ssh-ed25519 AAAA test', + }) + ); + // The command must have actually reached the device request — a + // config-resolution early-out would pass a naive signal assertion + // by never fetching at all. + assert.equal(result.success, true); + assert.ok(signals.length >= 2, `expected probe + request fetches, saw ${signals.length}`); + assert.ok( + signals.every(Boolean), + 'every device fetch (probe and ssh request) must carry a deadline signal' + ); + } finally { + if (originalXdg === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdg; + } + fs.rmSync(configHome, { recursive: true, force: true }); + } }); test('ff1 compatibility probe default fetch carries a deadline signal', async () => { From ca21143d879abed8653fe00cdc5cc53de656ea9f Mon Sep 17 00:00:00 2001 From: Sean Moss-Pultz Date: Mon, 27 Jul 2026 21:45:05 +0800 Subject: [PATCH 7/8] fix(review): render every progress callback, including done===total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reviewers caught it: suppressing the final callback meant a one-batch index — including the recommended --limit 5 warm-up — printed no progress at all, and multi-batch runs never showed completion. The final N/N line IS the indexing-finished signal; render unconditionally. Coverage: single batch → [1/1]; 12 tokens → [10/12, 12/12]; a throwing progress renderer never breaks the batch. 457/457. --- src/commands/find.ts | 8 +++-- tests/find-resolvers.test.ts | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/commands/find.ts b/src/commands/find.ts index 355fd59..a59811c 100644 --- a/src/commands/find.ts +++ b/src/commands/find.ts @@ -227,10 +227,12 @@ async function runResolvedTarget(target: ResolvedTarget, options: FindOptions): tokenId: t.tokenId, })), undefined, + // Unconditional: suppressing the done===total call meant a one-batch + // index (including the recommended --limit 5 warm-up) printed nothing + // and multi-batch runs never showed completion. The final N/N line IS + // the "indexing finished" signal. (done: number, total: number) => { - if (total > done) { - console.log(chalk.dim(` ${done}/${total} tokens indexed...`)); - } + console.log(chalk.dim(` ${done}/${total} tokens indexed...`)); } ); diff --git a/tests/find-resolvers.test.ts b/tests/find-resolvers.test.ts index 5d8d8d4..b6d4fab 100644 --- a/tests/find-resolvers.test.ts +++ b/tests/find-resolvers.test.ts @@ -900,3 +900,61 @@ describe('device request deadlines (#101 review)', () => { assert.equal(sawSignal, true); }); }); + +describe('getNFTTokenInfoBatch progress reporting (#101 review)', () => { + // A rejecting fetch makes every token fail fast (no indexer polling), so + // these tests exercise exactly the batching + progress mechanics. + const rejectingFetch = (async () => { + throw new TypeError('fetch failed'); + }) as FetchFn; + + test('single batch reports the final done===total call', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getNFTTokenInfoBatch } = require('../src/utilities/nft-indexer'); + const calls: Array<[number, number]> = []; + await withMockedFetch(rejectingFetch, () => + getNFTTokenInfoBatch( + [{ chain: 'ethereum', contractAddress: '0xabc', tokenId: '1' }], + undefined, + (done: number, total: number) => calls.push([done, total]) + ) + ); + assert.deepEqual(calls, [[1, 1]]); + }); + + test('multi-batch reports each batch including completion', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getNFTTokenInfoBatch } = require('../src/utilities/nft-indexer'); + const tokens = Array.from({ length: 12 }, (_unused, i) => ({ + chain: 'ethereum', + contractAddress: '0xabc', + tokenId: String(i), + })); + const calls: Array<[number, number]> = []; + await withMockedFetch(rejectingFetch, () => + getNFTTokenInfoBatch(tokens, undefined, (done: number, total: number) => + calls.push([done, total]) + ) + ); + // Concurrency is 10 per batch: 12 tokens → [10/12, 12/12]. + assert.deepEqual(calls, [ + [10, 12], + [12, 12], + ]); + }); + + test('a throwing progress callback never breaks the batch', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getNFTTokenInfoBatch } = require('../src/utilities/nft-indexer'); + const items = await withMockedFetch(rejectingFetch, () => + getNFTTokenInfoBatch( + [{ chain: 'ethereum', contractAddress: '0xabc', tokenId: '1' }], + undefined, + () => { + throw new Error('renderer exploded'); + } + ) + ); + assert.deepEqual(items, []); + }); +}); From 109cdfc30ea1a943109d0df02e3426108ea8ce88 Mon Sep 17 00:00:00 2001 From: Sean Moss-Pultz Date: Mon, 27 Jul 2026 22:12:44 +0800 Subject: [PATCH 8/8] fix(review): every production fetch default rides the deadline; Request signals compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 findings from all three reviewers, all valid: - ff1-device (probe + /api/cast), ff1-relayer, handoff-client, and feral-file-artwork all defaulted their injectable fetch to bare globalThis.fetch — so play, find --play, pairing, relayer delivery, and FF artwork resolution could still hang. All five defaults now use defaultDeadlineFetch (fetchWithTimeout with the standard deadline); injected test fetches are untouched. - fetchWithTimeout replaced a Request input's own abort signal with the deadline when init.signal was absent (init wins over the request's signal in fetch). The Request's signal is now composed via AbortSignal.any alongside the deadline. Coverage: defaultDeadlineFetch attaches a deadline; artwork resolver and relayer defaults carry one; a Request input's own abort propagates through the composed signal. 461/461. --- src/utilities/feral-file-artwork.ts | 3 +- src/utilities/ff1-device.ts | 5 +- src/utilities/ff1-relayer.ts | 4 +- src/utilities/handoff-client.ts | 4 +- src/utilities/http.ts | 22 ++++++++- tests/find-resolvers.test.ts | 77 +++++++++++++++++++++++++++++ 6 files changed, 110 insertions(+), 5 deletions(-) diff --git a/src/utilities/feral-file-artwork.ts b/src/utilities/feral-file-artwork.ts index 9318a3f..eb0f971 100644 --- a/src/utilities/feral-file-artwork.ts +++ b/src/utilities/feral-file-artwork.ts @@ -7,6 +7,7 @@ * endpoint instead of reconstructing contract identity from exhibition internals. */ +import { defaultDeadlineFetch } from './http'; import { USER_AGENT } from './user-agent'; export interface FeralFileArtworkTokenCoords { @@ -37,7 +38,7 @@ interface FeralFileArtworkResponse { */ export async function resolveFeralFileArtwork( artworkId: string, - fetchImpl: typeof fetch = fetch + fetchImpl: typeof fetch = defaultDeadlineFetch ): Promise { const normalizedArtworkId = artworkId.trim(); if (!normalizedArtworkId) { diff --git a/src/utilities/ff1-device.ts b/src/utilities/ff1-device.ts index 9836825..842d89e 100644 --- a/src/utilities/ff1-device.ts +++ b/src/utilities/ff1-device.ts @@ -3,6 +3,7 @@ * Handles sending DP1 playlists to FF1 devices via the Relayer API */ +import { defaultDeadlineFetch } from './http'; import * as logger from '../logger'; import { getFF1RelayerConfig } from '../config'; import type { FF1Device, Playlist } from '../types'; @@ -193,7 +194,9 @@ export async function sendPlaylistToDevice( overrides: FF1DeviceDependencies = {} ): Promise { const dependencies: DeliveryDependencies = { - fetchFn: overrides.fetchFn ?? globalThis.fetch.bind(globalThis), + // Production default rides the shared deadline; injected test fetches + // are preserved untouched (#101 review). + fetchFn: overrides.fetchFn ?? defaultDeadlineFetch, getTopicIdFn: overrides.getTopicIdFn ?? getTopicId, getRelayerConfigFn: overrides.getRelayerConfigFn ?? getFF1RelayerConfig, waitFn: overrides.waitFn ?? waitForRetry, diff --git a/src/utilities/ff1-relayer.ts b/src/utilities/ff1-relayer.ts index 5afcd3f..85bb2ba 100644 --- a/src/utilities/ff1-relayer.ts +++ b/src/utilities/ff1-relayer.ts @@ -1,3 +1,4 @@ +import { defaultDeadlineFetch } from './http'; import type { Playlist } from '../types'; export interface RelayerCastResult { @@ -39,7 +40,8 @@ export async function sendPlaylistViaRelayer(input: { playlist: Playlist; fetchFn?: typeof fetch; }): Promise { - const fetchFn = input.fetchFn ?? globalThis.fetch.bind(globalThis); + // Production default rides the shared deadline (#101 review). + const fetchFn = input.fetchFn ?? defaultDeadlineFetch; let url: URL; try { url = new URL('/api/cast', input.baseUrl); diff --git a/src/utilities/handoff-client.ts b/src/utilities/handoff-client.ts index 3f1b804..aa9f31e 100644 --- a/src/utilities/handoff-client.ts +++ b/src/utilities/handoff-client.ts @@ -1,3 +1,4 @@ +import { defaultDeadlineFetch } from './http'; import { webcrypto } from 'node:crypto'; import { HANDOFF_ALGORITHM, @@ -109,7 +110,8 @@ export class TopicHandoffReceiver { fetchFn?: typeof fetch; } = {} ): Promise { - const fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis); + // Production default rides the shared deadline (#101 review). + const fetchFn = options.fetchFn ?? defaultDeadlineFetch; const baseUrl = brokerBaseUrl(options.baseUrl); const keyPair = await generateHandoffKeyPair(); const publicKey = await exportHandoffPublicJwk(keyPair.publicKey); diff --git a/src/utilities/http.ts b/src/utilities/http.ts index 3703443..e9d2b81 100644 --- a/src/utilities/http.ts +++ b/src/utilities/http.ts @@ -19,6 +19,26 @@ export function fetchWithTimeout( timeoutMs: number = DEFAULT_HTTP_TIMEOUT_MS ): Promise { const deadline = AbortSignal.timeout(timeoutMs); - const signal = init.signal ? AbortSignal.any([init.signal, deadline]) : deadline; + const signals: AbortSignal[] = [deadline]; + if (init.signal) { + signals.push(init.signal); + } else if (input instanceof Request && input.signal) { + // A Request input carries its own abort signal; passing only the + // deadline in init would silently REPLACE it (init wins over the + // request's signal in fetch), stripping the caller's cancellation. + signals.push(input.signal); + } + const signal = signals.length > 1 ? AbortSignal.any(signals) : deadline; return fetch(input, { ...init, signal }); } + +/** + * Drop-in `fetch` for production defaults of injectable-fetch modules: + * identical signature, deadline attached. Modules that accept a `fetchFn` + * override default to this instead of bare `globalThis.fetch`, so tests + * keep full injection control while production can never hang. + */ +export const defaultDeadlineFetch: typeof fetch = ( + input: string | URL | Request, + init?: RequestInit +) => fetchWithTimeout(input, init ?? {}); diff --git a/tests/find-resolvers.test.ts b/tests/find-resolvers.test.ts index b6d4fab..e7c56f7 100644 --- a/tests/find-resolvers.test.ts +++ b/tests/find-resolvers.test.ts @@ -958,3 +958,80 @@ describe('getNFTTokenInfoBatch progress reporting (#101 review)', () => { assert.deepEqual(items, []); }); }); + +describe('production fetch defaults ride the deadline (#101 review round 2)', () => { + test('defaultDeadlineFetch attaches a deadline signal', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { defaultDeadlineFetch } = require('../src/utilities/http'); + let sawSignal = false; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + sawSignal = init?.signal instanceof AbortSignal; + return new Response('{}', { status: 200 }); + }) as FetchFn; + await withMockedFetch(mock, () => defaultDeadlineFetch('https://example.com')); + assert.equal(sawSignal, true); + }); + + test('feral-file artwork resolver default fetch carries a deadline signal', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { resolveFeralFileArtwork } = require('../src/utilities/feral-file-artwork'); + let sawSignal = false; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + sawSignal = init?.signal instanceof AbortSignal; + return new Response( + JSON.stringify({ + result: { + id: 'abc', + blockchainStatus: 'settled', + contractAddress: '0xabc', + tokenID: '1', + chain: 'ethereum', + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + }) as FetchFn; + await withMockedFetch(mock, () => resolveFeralFileArtwork('abc').catch(() => undefined)); + assert.equal(sawSignal, true); + }); + + test('relayer cast default fetch carries a deadline signal', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { sendPlaylistViaRelayer } = require('../src/utilities/ff1-relayer'); + let sawSignal = false; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + sawSignal = init?.signal instanceof AbortSignal; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as FetchFn; + await withMockedFetch(mock, () => + sendPlaylistViaRelayer({ + baseUrl: 'https://relayer.example.com', + topicId: 'topic', + playlist: { dpVersion: '1.1.0', id: 'x', slug: 'x', title: 'x', created: 'x', items: [] }, + }).catch(() => undefined) + ); + assert.equal(sawSignal, true); + }); + + test('Request input keeps its own abort signal composed with the deadline', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { fetchWithTimeout } = require('../src/utilities/http'); + const caller = new AbortController(); + let received: AbortSignal | undefined; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + received = init?.signal ?? undefined; + return new Response('{}', { status: 200 }); + }) as FetchFn; + await withMockedFetch(mock, () => + fetchWithTimeout(new Request('https://example.com', { signal: caller.signal })) + ); + assert.ok(received instanceof AbortSignal); + caller.abort(); + // The Request's own signal must propagate through the composed signal + // rather than being replaced by the deadline. + assert.equal(received?.aborted, true); + }); +});