web_fetch fixes

This commit is contained in:
Raven Scott
2026-04-22 01:46:29 -04:00
parent dd24b61400
commit b0165c0157
11 changed files with 229 additions and 33 deletions
+54 -9
View File
@@ -1765,6 +1765,40 @@ function bareWebResolveFetch(ctx) {
return null return null
} }
/**
* bundled bare-fetch rejects the fetch promise with `signal.reason` on abort.
* `controller.abort()` with no argument sets `reason === undefined`, so callers
* see `promise_rejected_with_undefined`. Always pass an explicit reason.
* @param {number} timeoutMs
*/
function bareWebTimeoutAbortReason(timeoutMs) {
const msg = 'web_fetch: exceeded ' + timeoutMs + 'ms (timeout)'
try {
if (typeof DOMException === 'function')
return new DOMException(msg, 'TimeoutError')
} catch {
/* ignore */
}
const e = new Error(msg)
e.name = 'TimeoutError'
return e
}
/**
* @param {AbortSignal} sig
*/
function bareWebSignalAbortReason(sig) {
try {
const r = /** @type {{ reason?: unknown }} */ (sig).reason
if (r !== undefined && r !== null) return r
} catch {
/* ignore */
}
const e = new Error('web_fetch aborted (signal)')
e.name = 'AbortError'
return e
}
/** /**
* @param {AbortSignal | null | undefined} a * @param {AbortSignal | null | undefined} a
* @param {AbortSignal | null | undefined} b * @param {AbortSignal | null | undefined} b
@@ -1774,18 +1808,23 @@ function bareWebUnionAbort(a, b) {
if (!b) return a if (!b) return a
if (typeof AbortSignal.any === 'function') return AbortSignal.any([a, b]) if (typeof AbortSignal.any === 'function') return AbortSignal.any([a, b])
const c = new AbortController() const c = new AbortController()
const fn = () => { /**
* @param {AbortSignal} sig
*/
const forward = (sig) => {
try { try {
c.abort() c.abort(bareWebSignalAbortReason(sig))
} catch { } catch {
/* ignore */ /* ignore — second source may fire after controller already aborted */
} }
} }
try { try {
if (a.aborted) fn() const as = /** @type {AbortSignal} */ (a)
else a.addEventListener('abort', fn, { once: true }) const bs = /** @type {AbortSignal} */ (b)
if (b.aborted) fn() if (as.aborted) forward(as)
else b.addEventListener('abort', fn, { once: true }) else as.addEventListener('abort', () => forward(as), { once: true })
if (bs.aborted) forward(bs)
else bs.addEventListener('abort', () => forward(bs), { once: true })
} catch { } catch {
/* ignore */ /* ignore */
} }
@@ -2109,7 +2148,7 @@ async function bareWebRunTool(o) {
const controller = new AbortController() const controller = new AbortController()
const timer = setTimeout(() => { const timer = setTimeout(() => {
try { try {
controller.abort() controller.abort(bareWebTimeoutAbortReason(timeoutMs))
} catch { } catch {
/* ignore */ /* ignore */
} }
@@ -2152,7 +2191,13 @@ async function bareWebRunTool(o) {
res = await fetchFn(currentUrl, init) res = await fetchFn(currentUrl, init)
} catch (e) { } catch (e) {
clearTimeout(timer) clearTimeout(timer)
const msg = bareWebFmtErr(e) const msg = bareWebFmtErr(
e === undefined
? new Error(
'web_fetch: fetch rejected with undefined (bare-fetch uses signal.reason; upstream abort() had no reason)'
)
: e
)
return { return {
ok: false, ok: false,
error: 'web_fetch: request failed: ' + msg.slice(0, 400), error: 'web_fetch: request failed: ' + msg.slice(0, 400),
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"schema": 2, "schema": 2,
"profileId": "bare-os-posix-like", "profileId": "bare-os-posix-like",
"generatedAt": "2026-04-22T05:38:34.635Z", "generatedAt": "2026-04-22T05:45:50.442Z",
"note": "Sparse POSIX Issue 7 coverage hints for /bin utilities. Omitted command names are not yet profiled here.", "note": "Sparse POSIX Issue 7 coverage hints for /bin utilities. Omitted command names are not yet profiled here.",
"commandIndex": [ "commandIndex": [
{ {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"schema": 1, "schema": 1,
"atMs": 1776836314634, "atMs": 1776836750441,
"commands": [ "commands": [
"agent", "agent",
"arch", "arch",
File diff suppressed because one or more lines are too long
@@ -100,6 +100,40 @@ function bareWebResolveFetch(ctx) {
return null return null
} }
/**
* bundled bare-fetch rejects the fetch promise with `signal.reason` on abort.
* `controller.abort()` with no argument sets `reason === undefined`, so callers
* see `promise_rejected_with_undefined`. Always pass an explicit reason.
* @param {number} timeoutMs
*/
function bareWebTimeoutAbortReason(timeoutMs) {
const msg = 'web_fetch: exceeded ' + timeoutMs + 'ms (timeout)'
try {
if (typeof DOMException === 'function')
return new DOMException(msg, 'TimeoutError')
} catch {
/* ignore */
}
const e = new Error(msg)
e.name = 'TimeoutError'
return e
}
/**
* @param {AbortSignal} sig
*/
function bareWebSignalAbortReason(sig) {
try {
const r = /** @type {{ reason?: unknown }} */ (sig).reason
if (r !== undefined && r !== null) return r
} catch {
/* ignore */
}
const e = new Error('web_fetch aborted (signal)')
e.name = 'AbortError'
return e
}
/** /**
* @param {AbortSignal | null | undefined} a * @param {AbortSignal | null | undefined} a
* @param {AbortSignal | null | undefined} b * @param {AbortSignal | null | undefined} b
@@ -109,18 +143,23 @@ function bareWebUnionAbort(a, b) {
if (!b) return a if (!b) return a
if (typeof AbortSignal.any === 'function') return AbortSignal.any([a, b]) if (typeof AbortSignal.any === 'function') return AbortSignal.any([a, b])
const c = new AbortController() const c = new AbortController()
const fn = () => { /**
* @param {AbortSignal} sig
*/
const forward = (sig) => {
try { try {
c.abort() c.abort(bareWebSignalAbortReason(sig))
} catch { } catch {
/* ignore */ /* ignore — second source may fire after controller already aborted */
} }
} }
try { try {
if (a.aborted) fn() const as = /** @type {AbortSignal} */ (a)
else a.addEventListener('abort', fn, { once: true }) const bs = /** @type {AbortSignal} */ (b)
if (b.aborted) fn() if (as.aborted) forward(as)
else b.addEventListener('abort', fn, { once: true }) else as.addEventListener('abort', () => forward(as), { once: true })
if (bs.aborted) forward(bs)
else bs.addEventListener('abort', () => forward(bs), { once: true })
} catch { } catch {
/* ignore */ /* ignore */
} }
@@ -444,7 +483,7 @@ async function bareWebRunTool(o) {
const controller = new AbortController() const controller = new AbortController()
const timer = setTimeout(() => { const timer = setTimeout(() => {
try { try {
controller.abort() controller.abort(bareWebTimeoutAbortReason(timeoutMs))
} catch { } catch {
/* ignore */ /* ignore */
} }
@@ -487,7 +526,13 @@ async function bareWebRunTool(o) {
res = await fetchFn(currentUrl, init) res = await fetchFn(currentUrl, init)
} catch (e) { } catch (e) {
clearTimeout(timer) clearTimeout(timer)
const msg = bareWebFmtErr(e) const msg = bareWebFmtErr(
e === undefined
? new Error(
'web_fetch: fetch rejected with undefined (bare-fetch uses signal.reason; upstream abort() had no reason)'
)
: e
)
return { return {
ok: false, ok: false,
error: 'web_fetch: request failed: ' + msg.slice(0, 400), error: 'web_fetch: request failed: ' + msg.slice(0, 400),
@@ -156,3 +156,38 @@ test('bareWebFmtErr null/undefined and node-like errors', async (t) => {
withCause.cause = new Error('inner') withCause.cause = new Error('inner')
t.ok(fmt(withCause).includes('inner')) t.ok(fmt(withCause).includes('inner'))
}) })
test('bareWebRunTool timeout passes abort reason (matches bare-fetch contract)', async (t) => {
/** Simulates bare-fetch: rejects with `signal.reason` when aborted. */
const fetchFn = async (_url, init) => {
const sig = init && /** @type {{ signal?: AbortSignal }} */ (init).signal
if (!sig)
return new Promise(() => {
/* hang */
})
return new Promise((_res, rej) => {
if (sig.aborted) {
rej(/** @type {AbortSignal} */ (sig).reason)
return
}
sig.addEventListener(
'abort',
() => rej(/** @type {AbortSignal} */ (sig).reason),
{ once: true }
)
})
}
const s = loadSandbox({ fetch: fetchFn })
const run = /** @type {typeof bareWebRunTool} */ (s.bareWebRunTool)
const out = await run({
ctx: { httpFetch: fetchFn },
url: 'https://slow.example/hang',
format: 'meta',
timeout_ms: 25
})
t.absent(out.ok)
const err = String(/** @type {{ error?: string }} */ (out).error || '')
t.ok(err.includes('web_fetch: request failed'))
t.ok(err.includes('timeout') || err.includes('TimeoutError') || err.includes('exceeded'))
t.absent(err.includes('promise_rejected_with_undefined'))
})
+54 -9
View File
@@ -1765,6 +1765,40 @@ function bareWebResolveFetch(ctx) {
return null return null
} }
/**
* bundled bare-fetch rejects the fetch promise with `signal.reason` on abort.
* `controller.abort()` with no argument sets `reason === undefined`, so callers
* see `promise_rejected_with_undefined`. Always pass an explicit reason.
* @param {number} timeoutMs
*/
function bareWebTimeoutAbortReason(timeoutMs) {
const msg = 'web_fetch: exceeded ' + timeoutMs + 'ms (timeout)'
try {
if (typeof DOMException === 'function')
return new DOMException(msg, 'TimeoutError')
} catch {
/* ignore */
}
const e = new Error(msg)
e.name = 'TimeoutError'
return e
}
/**
* @param {AbortSignal} sig
*/
function bareWebSignalAbortReason(sig) {
try {
const r = /** @type {{ reason?: unknown }} */ (sig).reason
if (r !== undefined && r !== null) return r
} catch {
/* ignore */
}
const e = new Error('web_fetch aborted (signal)')
e.name = 'AbortError'
return e
}
/** /**
* @param {AbortSignal | null | undefined} a * @param {AbortSignal | null | undefined} a
* @param {AbortSignal | null | undefined} b * @param {AbortSignal | null | undefined} b
@@ -1774,18 +1808,23 @@ function bareWebUnionAbort(a, b) {
if (!b) return a if (!b) return a
if (typeof AbortSignal.any === 'function') return AbortSignal.any([a, b]) if (typeof AbortSignal.any === 'function') return AbortSignal.any([a, b])
const c = new AbortController() const c = new AbortController()
const fn = () => { /**
* @param {AbortSignal} sig
*/
const forward = (sig) => {
try { try {
c.abort() c.abort(bareWebSignalAbortReason(sig))
} catch { } catch {
/* ignore */ /* ignore — second source may fire after controller already aborted */
} }
} }
try { try {
if (a.aborted) fn() const as = /** @type {AbortSignal} */ (a)
else a.addEventListener('abort', fn, { once: true }) const bs = /** @type {AbortSignal} */ (b)
if (b.aborted) fn() if (as.aborted) forward(as)
else b.addEventListener('abort', fn, { once: true }) else as.addEventListener('abort', () => forward(as), { once: true })
if (bs.aborted) forward(bs)
else bs.addEventListener('abort', () => forward(bs), { once: true })
} catch { } catch {
/* ignore */ /* ignore */
} }
@@ -2109,7 +2148,7 @@ async function bareWebRunTool(o) {
const controller = new AbortController() const controller = new AbortController()
const timer = setTimeout(() => { const timer = setTimeout(() => {
try { try {
controller.abort() controller.abort(bareWebTimeoutAbortReason(timeoutMs))
} catch { } catch {
/* ignore */ /* ignore */
} }
@@ -2152,7 +2191,13 @@ async function bareWebRunTool(o) {
res = await fetchFn(currentUrl, init) res = await fetchFn(currentUrl, init)
} catch (e) { } catch (e) {
clearTimeout(timer) clearTimeout(timer)
const msg = bareWebFmtErr(e) const msg = bareWebFmtErr(
e === undefined
? new Error(
'web_fetch: fetch rejected with undefined (bare-fetch uses signal.reason; upstream abort() had no reason)'
)
: e
)
return { return {
ok: false, ok: false,
error: 'web_fetch: request failed: ' + msg.slice(0, 400), error: 'web_fetch: request failed: ' + msg.slice(0, 400),
@@ -1,7 +1,7 @@
{ {
"schema": 2, "schema": 2,
"profileId": "bare-os-posix-like", "profileId": "bare-os-posix-like",
"generatedAt": "2026-04-22T05:38:34.635Z", "generatedAt": "2026-04-22T05:45:50.442Z",
"note": "Sparse POSIX Issue 7 coverage hints for /bin utilities. Omitted command names are not yet profiled here.", "note": "Sparse POSIX Issue 7 coverage hints for /bin utilities. Omitted command names are not yet profiled here.",
"commandIndex": [ "commandIndex": [
{ {
@@ -1,6 +1,6 @@
{ {
"schema": 1, "schema": 1,
"atMs": 1776836314634, "atMs": 1776836750441,
"commands": [ "commands": [
"agent", "agent",
"arch", "arch",
File diff suppressed because one or more lines are too long
+26
View File
@@ -112,6 +112,32 @@ async function main() {
return return
} }
/** Same contract as bare-fetch: reject with `signal.reason` when aborted. */
async function hangUntilAbort(_url, init) {
const sig = init && init.signal
if (!sig) return new Promise(() => {})
return new Promise((_res, rej) => {
if (sig.aborted) rej(sig.reason)
else sig.addEventListener('abort', () => rej(sig.reason), { once: true })
})
}
const timeoutProbe = await bareWebRunToolFn({
ctx: { httpFetch: hangUntilAbort },
url: 'https://hang.test/',
format: 'meta',
timeout_ms: 50
})
if (timeoutProbe.ok)
throw new Error('[smoke-bare-web-fetch] timeout probe expected ok=false')
const te = String(timeoutProbe.error || '')
if (te.includes('promise_rejected_with_undefined'))
throw new Error(
'[smoke-bare-web-fetch] abort must carry a reason for bare-fetch: ' + te
)
if (!/timeout|TimeoutError|exceeded/i.test(te))
throw new Error('[smoke-bare-web-fetch] expected timeout message: ' + te)
console.log('[smoke-bare-web-fetch] timeout-abort contract ok')
const result = await bareWebRunToolFn({ const result = await bareWebRunToolFn({
ctx: { httpFetch: fetch }, ctx: { httpFetch: fetch },
url: 'https://example.com/', url: 'https://example.com/',