@qadanm/opencode-smart-router 0.4.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +168 -0
- package/CONTRIBUTING.md +7 -0
- package/README.md +89 -5
- package/config.example.json +1 -2
- package/dist/npm/index.js +29 -29
- package/docs/INSTALL.md +35 -4
- package/docs/LIMITATIONS.md +19 -2
- package/docs/SHARED-CORE.md +47 -5
- package/package.json +3 -1
- package/src/core/config.ts +25 -8
- package/src/core/models/registry.ts +1 -0
- package/src/core/models/seed.ts +13 -3
- package/src/core/plans/catalog.ts +6 -0
- package/src/core/policy/eligibility.ts +53 -1
- package/src/core/providers/opendesign-models.ts +33 -7
- package/src/core/quota/ledger.ts +31 -0
- package/src/core/routing/engine.ts +61 -2
- package/src/core/types.ts +46 -5
- package/src/gui/index.tsx +5 -0
- package/src/index.ts +15 -30
- package/src/server/containment.ts +131 -0
- package/src/server/controller.ts +261 -137
- package/src/server/shared-core.ts +32 -2
- package/dist/allowance.js +0 -487
- package/dist/gui-experimental.cjs +0 -1
- package/dist/index.js +0 -11111
- package/dist/tui.js +0 -587
|
@@ -209,6 +209,35 @@ export const estimateTaskCost = (input: {
|
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Which capability axis each task kind reads, and the benchmark-neutral point
|
|
214
|
+
* on that axis. Kinds with no axis get no nudge. Neutral points sit at the
|
|
215
|
+
* middle of the observed benchmark band, so a score above the band's middle
|
|
216
|
+
* helps and one below it hurts - by at most CAPABILITY_MAX_NUDGE.
|
|
217
|
+
*/
|
|
218
|
+
const CAPABILITY_AXIS: Partial<Record<TaskClass["kind"], keyof NonNullable<ModelRecord["capabilities"]>>> = {
|
|
219
|
+
feature: "coding",
|
|
220
|
+
debug: "debugging",
|
|
221
|
+
refactor: "coding",
|
|
222
|
+
design: "architecture",
|
|
223
|
+
architecture: "architecture",
|
|
224
|
+
security: "architecture",
|
|
225
|
+
test: "coding",
|
|
226
|
+
docs: undefined,
|
|
227
|
+
deterministic: undefined,
|
|
228
|
+
mechanical: undefined,
|
|
229
|
+
unknown: undefined,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const CAPABILITY_NEUTRAL: Record<"coding" | "debugging" | "agentic" | "architecture", number> = {
|
|
233
|
+
coding: 60,
|
|
234
|
+
debugging: 68,
|
|
235
|
+
agentic: 55,
|
|
236
|
+
architecture: 80,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const CAPABILITY_MAX_NUDGE = 0.04
|
|
240
|
+
|
|
212
241
|
const observedSuccess = (model: ModelRecord, session: EngineInput["session"], _key: string): number => {
|
|
213
242
|
// Only capability failures should reduce a model's expected success rate.
|
|
214
243
|
// Provider, network, auth, tool, and context-overflow errors are handled by
|
|
@@ -249,6 +278,21 @@ export const estimateSuccess = (input: {
|
|
|
249
278
|
const threshold = task.sophisticatedDesign ? 75 : 60
|
|
250
279
|
if (model.design < threshold) probability *= 0.72
|
|
251
280
|
}
|
|
281
|
+
// Per-kind capability evidence. A model with no measured capability for the
|
|
282
|
+
// task's kind stays neutral: an unscored model is not a zero-capability
|
|
283
|
+
// model, and inventing a score would poison every ranking it entered. With
|
|
284
|
+
// evidence, the benchmark gap moves the probability by at most a few points
|
|
285
|
+
// either way - enough to break a close ranking in favor of the model that
|
|
286
|
+
// measurably does this kind of work better, never enough to overrule a
|
|
287
|
+
// context, quota, or floor gate.
|
|
288
|
+
if (model.capabilities && CAPABILITY_AXIS[task.kind]) {
|
|
289
|
+
const axis = CAPABILITY_AXIS[task.kind]!
|
|
290
|
+
const score = model.capabilities[axis]
|
|
291
|
+
if (typeof score === "number") {
|
|
292
|
+
const edge = score - CAPABILITY_NEUTRAL[axis]
|
|
293
|
+
probability += Math.max(-CAPABILITY_MAX_NUDGE, Math.min(CAPABILITY_MAX_NUDGE, edge / 500))
|
|
294
|
+
}
|
|
295
|
+
}
|
|
252
296
|
const floor = input.intelligenceFloor ?? 39.5
|
|
253
297
|
if (task.tier >= 2 && model.intelligence < floor) probability *= 0.7
|
|
254
298
|
if (model.context > 0 && input.contextTokens > model.context * 0.8) probability *= 0.9
|
|
@@ -619,7 +663,16 @@ export const routeTask = (input: EngineInput): EngineResult => {
|
|
|
619
663
|
// Only protect the escalation pool when an ordinary-allowance route can
|
|
620
664
|
// actually serve the task. A task that needs escalation must still be
|
|
621
665
|
// able to escalate.
|
|
622
|
-
|
|
666
|
+
// A tier-3 task served by a standard model runs at ~0.7 expected
|
|
667
|
+
// success by construction (estimateSuccess). That is below the bar a
|
|
668
|
+
// tier-3 model meets, so for tier-3 work no ordinary route "serves" the
|
|
669
|
+
// task and the pool stays available for it. Tier 1/2 work meets the bar
|
|
670
|
+
// at 0.93+, so the pool stays protected for ordinary tasks and can never
|
|
671
|
+
// be drained by routine work.
|
|
672
|
+
if (input.task.tier >= 3) return unprotected
|
|
673
|
+
const ordinaryAvailable = unprotected.some(
|
|
674
|
+
(entry) => !entry.model.escalationOnly && !entry.model.premium && entry.probability >= 0.85,
|
|
675
|
+
)
|
|
623
676
|
if (!ordinaryAvailable) return unprotected
|
|
624
677
|
return run(true)
|
|
625
678
|
}
|
|
@@ -873,8 +926,14 @@ export const routeTask = (input: EngineInput): EngineResult => {
|
|
|
873
926
|
)
|
|
874
927
|
reasons.push(...quotaNotes)
|
|
875
928
|
|
|
929
|
+
// The trace keeps the twelve cheapest eligible routes plus, always, the
|
|
930
|
+
// chosen model: on an escalation the flagship is the most expensive row, so
|
|
931
|
+
// a bare cost-ordered slice would truncate exactly the entry an escalation
|
|
932
|
+
// exists to show.
|
|
933
|
+
const chosenEntry = chosen
|
|
876
934
|
const candidates = evaluated
|
|
877
|
-
.
|
|
935
|
+
.sort((a, b) => a.candidate.effectiveCost - b.candidate.effectiveCost)
|
|
936
|
+
.filter((entry, index) => index < 12 || entry === chosenEntry)
|
|
878
937
|
.map((entry) => ({
|
|
879
938
|
...entry.candidate,
|
|
880
939
|
retained: keptForAffinity ? entry.model === currentEval?.model : entry.model === chosen.model,
|
package/src/core/types.ts
CHANGED
|
@@ -99,6 +99,21 @@ export interface ModelRecord {
|
|
|
99
99
|
readonly intelligence: number
|
|
100
100
|
/** Curated front-end and UX quality score. */
|
|
101
101
|
readonly design: number
|
|
102
|
+
/**
|
|
103
|
+
* Per-kind capability evidence, when measured benchmarks exist. Absent
|
|
104
|
+
* axes are unknown, never zero: an unscored model stays neutral in
|
|
105
|
+
* ranking rather than being treated as incapable.
|
|
106
|
+
*/
|
|
107
|
+
readonly capabilities?: {
|
|
108
|
+
/** Agentic coding benchmark, percent. */
|
|
109
|
+
readonly coding?: number
|
|
110
|
+
/** Complex-debugging / automation benchmark, percent. */
|
|
111
|
+
readonly debugging?: number
|
|
112
|
+
/** Long-horizon autonomous-execution benchmark, percent. */
|
|
113
|
+
readonly agentic?: number
|
|
114
|
+
/** Curated architectural-reasoning score, same 0-100 scale. */
|
|
115
|
+
readonly architecture?: number
|
|
116
|
+
}
|
|
102
117
|
readonly designEvidence: "benchmark" | "curated" | "observed" | "unverified"
|
|
103
118
|
/**
|
|
104
119
|
* Credit multiplier applied to this model's usage value. One for every
|
|
@@ -283,10 +298,16 @@ export interface ProviderQuotaSnapshot {
|
|
|
283
298
|
}
|
|
284
299
|
|
|
285
300
|
export interface PremiumState {
|
|
286
|
-
readonly mode: "off" | "ask" | "allowed"
|
|
301
|
+
readonly mode: "off" | "ask" | "allowed" | "auto"
|
|
287
302
|
readonly monthlyBudgetUsd: number
|
|
288
303
|
readonly spentUsd: number
|
|
289
304
|
readonly remainingUsd: number
|
|
305
|
+
/**
|
|
306
|
+
* Remaining plan-model pool allowance for the month. In auto mode this, not
|
|
307
|
+
* `remainingUsd`, is what escalation draws against; undefined when the
|
|
308
|
+
* caller has no pool identity for the model in play.
|
|
309
|
+
*/
|
|
310
|
+
readonly planPoolRemainingUsd?: number
|
|
290
311
|
readonly oneShotGranted: boolean
|
|
291
312
|
readonly oneShotGrantedAt?: number
|
|
292
313
|
}
|
|
@@ -402,6 +423,8 @@ export interface Metrics {
|
|
|
402
423
|
dryRunDecisions: number
|
|
403
424
|
premiumRequests: number
|
|
404
425
|
premiumUsd: number
|
|
426
|
+
/** Lifetime spend against the plan-model pool (escalation allowance). */
|
|
427
|
+
planPoolUsd: number
|
|
405
428
|
latencyMsTotal: number
|
|
406
429
|
latencySamples: number
|
|
407
430
|
lastLatencyMs?: number
|
|
@@ -454,6 +477,13 @@ export interface Ledger {
|
|
|
454
477
|
/** Hourly aggregates for anything older than the raw-event horizon. */
|
|
455
478
|
usageRollup?: UsageRollup
|
|
456
479
|
premiumSpend: Array<{ month: string; usd: number }>
|
|
480
|
+
/**
|
|
481
|
+
* Monthly spend against the plan-model pool (the escalation allowance),
|
|
482
|
+
* separate from `premiumSpend` so the pool gate reads exactly what the
|
|
483
|
+
* pool funded. The hydrator always materializes this array, so ledgers
|
|
484
|
+
* written before the field existed read as zero spend.
|
|
485
|
+
*/
|
|
486
|
+
planPoolSpend: Array<{ month: string; usd: number }>
|
|
457
487
|
sessions: Record<string, SessionState>
|
|
458
488
|
decisions: RoutingDecision[]
|
|
459
489
|
metrics: Metrics
|
|
@@ -597,7 +627,7 @@ export interface PrivacySettings {
|
|
|
597
627
|
}
|
|
598
628
|
|
|
599
629
|
export interface PremiumSettings {
|
|
600
|
-
readonly mode: "off" | "ask" | "allowed"
|
|
630
|
+
readonly mode: "off" | "ask" | "allowed" | "auto"
|
|
601
631
|
readonly monthlyBudgetUsd: number
|
|
602
632
|
readonly oneShotApproval: boolean
|
|
603
633
|
}
|
|
@@ -611,7 +641,20 @@ export interface RoutingSettings {
|
|
|
611
641
|
readonly largeContextHysteresisPct: number
|
|
612
642
|
readonly maxEscalationsPerTask: number
|
|
613
643
|
readonly preferSameProvider: boolean
|
|
614
|
-
/**
|
|
644
|
+
/**
|
|
645
|
+
* Block a prompt when this plugin cannot produce a routing decision.
|
|
646
|
+
*
|
|
647
|
+
* Default false, and that default is the point: routing is an optimization,
|
|
648
|
+
* and a defect inside the router must degrade to "do not route", never to
|
|
649
|
+
* "do not work". An internal error leaves the current model in place and lets
|
|
650
|
+
* the prompt through.
|
|
651
|
+
*
|
|
652
|
+
* This does NOT govern quota safety. A provider that is configured to be
|
|
653
|
+
* probed but has no reading is always refused, because that is a spending
|
|
654
|
+
* decision about a provider the router cannot see, not a response to its own
|
|
655
|
+
* failure. Set this true only if you would rather be stopped than pay for an
|
|
656
|
+
* unrouted request.
|
|
657
|
+
*/
|
|
615
658
|
readonly failClosed: boolean
|
|
616
659
|
/**
|
|
617
660
|
* Refuse to route a task to a model funded from a pool reserved for
|
|
@@ -686,8 +729,6 @@ export interface SpeedSettings {
|
|
|
686
729
|
}
|
|
687
730
|
|
|
688
731
|
export interface UiSettings {
|
|
689
|
-
/** Prefix the session title with AUTO / MANUAL / PAUSED / DRY RUN and the current model. */
|
|
690
|
-
readonly titleIndicator: boolean
|
|
691
732
|
/** Send a native macOS notification when the provider or model changes. */
|
|
692
733
|
readonly notifications: boolean
|
|
693
734
|
/** Use a shorter, denser indicator title and UI chrome (compact mode). */
|
package/src/gui/index.tsx
CHANGED
|
@@ -1443,6 +1443,8 @@ function SmartRouterPanel(props: { sessionID: string | undefined }) {
|
|
|
1443
1443
|
* happens within a frame or two; everywhere else the answer is "no" and
|
|
1444
1444
|
* nothing changes.
|
|
1445
1445
|
*/
|
|
1446
|
+
|
|
1447
|
+
|
|
1446
1448
|
void (async () => {
|
|
1447
1449
|
try {
|
|
1448
1450
|
const state: any = await Promise.race([
|
|
@@ -1456,9 +1458,11 @@ function SmartRouterPanel(props: { sessionID: string | undefined }) {
|
|
|
1456
1458
|
}
|
|
1457
1459
|
})()
|
|
1458
1460
|
const timer = setInterval(() => void load(), 3_000)
|
|
1461
|
+
// The panel only reflects the window-scoped controller's state.
|
|
1459
1462
|
onCleanup(() => clearInterval(timer))
|
|
1460
1463
|
})
|
|
1461
1464
|
|
|
1465
|
+
|
|
1462
1466
|
const view = (): DecisionView | undefined => summariseDecision(status()?.lastDecision as never)
|
|
1463
1467
|
const providers = (): ProviderRow[] =>
|
|
1464
1468
|
((status()?.quota ?? []) as never[]).map((snapshot) => toProviderRow(snapshot))
|
|
@@ -1651,6 +1655,7 @@ export default Plugin.define({
|
|
|
1651
1655
|
*/
|
|
1652
1656
|
const autoOpenTimer = setInterval(tryAutoOpen, 800)
|
|
1653
1657
|
|
|
1658
|
+
|
|
1654
1659
|
ctx.add(Slot, {
|
|
1655
1660
|
at: "window.bottom",
|
|
1656
1661
|
render: () => (
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { Plugin } from "@opencode/plugin"
|
|
2
|
-
import {
|
|
3
|
-
import { stripTitlePrefix } from "./core/ui.js"
|
|
2
|
+
import { RouterController } from "./server/controller.js"
|
|
4
3
|
import { sharedCore } from "./server/shared-core.js"
|
|
5
4
|
|
|
6
5
|
const describe = (error: unknown) => (error instanceof Error ? error.message : String(error))
|
|
@@ -12,36 +11,22 @@ export default Plugin.define({
|
|
|
12
11
|
try {
|
|
13
12
|
return await controller.initialize()
|
|
14
13
|
} catch (error) {
|
|
15
|
-
// A broken location must not take down the host or the other bridges
|
|
14
|
+
// A broken location must not take down the host or the other bridges, and
|
|
15
|
+
// it must not take the owner's ability to send a prompt with it.
|
|
16
|
+
//
|
|
17
|
+
// This branch used to install a "minimal fail-closed hook" that rejected
|
|
18
|
+
// every prompt until the next reload. That turned a failed setup into a
|
|
19
|
+
// disabled editor, which is the worst possible response to a plugin that
|
|
20
|
+
// is already not working. Verified in scripts/isolated-e2e.sh: a plugin
|
|
21
|
+
// hook that throws fails the request outright, so a hook here is not a
|
|
22
|
+
// safety net.
|
|
23
|
+
//
|
|
24
|
+
// The correct outcome is that this location has no router. OpenCode then
|
|
25
|
+
// behaves exactly as it would with the plugin uninstalled, which is the
|
|
26
|
+
// guarantee the plugin owes its host. The failure stays visible in
|
|
27
|
+
// diagnostics and in the status RPC of any bridge that did start.
|
|
16
28
|
sharedCore.recordError(`setup ${ctx.location?.directory ?? ""}: ${describe(error)}`)
|
|
17
29
|
await controller.dispose().catch(() => undefined)
|
|
18
|
-
// A broken location must not silently execute prompts with no routing.
|
|
19
|
-
// Keep a minimal fail-closed hook until the next reload.
|
|
20
|
-
try {
|
|
21
|
-
await ctx.session.hook("prompt", async (event: any) => {
|
|
22
|
-
const sessionID = event?.sessionID
|
|
23
|
-
if (!sessionID) throw new RouterBlockedError("Smart Router setup failed")
|
|
24
|
-
let info: any
|
|
25
|
-
try {
|
|
26
|
-
info = await ctx.session.get({ sessionID })
|
|
27
|
-
} catch {
|
|
28
|
-
info = undefined
|
|
29
|
-
}
|
|
30
|
-
const localID = ctx.location?.project?.id
|
|
31
|
-
if (info?.projectID && localID && info.projectID !== localID) return
|
|
32
|
-
if (info) {
|
|
33
|
-
try {
|
|
34
|
-
const base = stripTitlePrefix(typeof info.title === "string" ? info.title : "") || "Session"
|
|
35
|
-
await ctx.session.update({ sessionID, title: `BLOCKED · router setup failed — ${base}` })
|
|
36
|
-
} catch {
|
|
37
|
-
// The rejection below still stops admission.
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
throw new RouterBlockedError("Smart Router setup failed; routing is unavailable")
|
|
41
|
-
})
|
|
42
|
-
} catch (hookError) {
|
|
43
|
-
sharedCore.recordError(`setup fallback hook ${ctx.location?.directory ?? ""}: ${describe(hookError)}`)
|
|
44
|
-
}
|
|
45
30
|
return () => undefined
|
|
46
31
|
}
|
|
47
32
|
},
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Containment for the host boundary.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS IS FOR. OpenCode does not protect itself from a plugin: a hook that
|
|
5
|
+
* throws fails the request, and a hook that never settles leaves the editor
|
|
6
|
+
* waiting forever. Verified against a real OpenCode process on 2026-11-06, in
|
|
7
|
+
* `scripts/isolated-e2e.sh` -- see the `hook-throws` and `hook-hangs`
|
|
8
|
+
* scenarios.
|
|
9
|
+
*
|
|
10
|
+
* That makes "the host is robust" an assumption this plugin cannot rely on.
|
|
11
|
+
* The only place the guarantee can come from is here: whatever the router does
|
|
12
|
+
* internally, the function OpenCode calls must return, and must not throw.
|
|
13
|
+
*
|
|
14
|
+
* TWO RULES.
|
|
15
|
+
*
|
|
16
|
+
* 1. A hook never propagates an exception. The prompt is served unrouted, which
|
|
17
|
+
* is exactly what would have happened if the plugin were not installed.
|
|
18
|
+
*
|
|
19
|
+
* 2. A hook never waits forever. Routing runs against a deadline; if the router
|
|
20
|
+
* is still working when it expires, the prompt proceeds without it. The
|
|
21
|
+
* router is an optimization, and an optimization that cannot finish is
|
|
22
|
+
* worth nothing -- but only if it gets out of the way.
|
|
23
|
+
*
|
|
24
|
+
* This module has no dependencies and no state, so it cannot be part of the
|
|
25
|
+
* thing that fails.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The narrow shape a contained hook needs from the plugin's own error type. */
|
|
29
|
+
interface BlockedLike {
|
|
30
|
+
readonly name?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const isBlocked = (error: unknown): boolean =>
|
|
34
|
+
typeof error === "object" &&
|
|
35
|
+
error !== null &&
|
|
36
|
+
((error as BlockedLike).name === "RouterBlockedError" || (error as any).routerBlocked === true)
|
|
37
|
+
|
|
38
|
+
export interface ContainOptions {
|
|
39
|
+
/** What to call this hook in a diagnostic. Never user-facing. */
|
|
40
|
+
readonly label: string
|
|
41
|
+
/** Called with the reason a hook was contained. Must not throw. */
|
|
42
|
+
readonly onContained?: (message: string) => void
|
|
43
|
+
/**
|
|
44
|
+
* Whether an exception is a deliberate refusal that must reach the host.
|
|
45
|
+
* Deliberate refusals are the one thing that may still stop a prompt,
|
|
46
|
+
* because they are a decision rather than a defect.
|
|
47
|
+
*/
|
|
48
|
+
readonly allowBlocked?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const describe = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wrap a hook so it can never throw and never hang.
|
|
55
|
+
*
|
|
56
|
+
* The returned function always resolves. A `RouterBlockedError` is re-thrown
|
|
57
|
+
* when `allowBlocked` is set, because that is the router saying "I refuse",
|
|
58
|
+
* which is a supported outcome. Everything else is a defect, and a defect
|
|
59
|
+
* leaves the prompt alone.
|
|
60
|
+
*/
|
|
61
|
+
export const containHook = <TEvent>(
|
|
62
|
+
handler: (event: TEvent) => Promise<unknown> | unknown,
|
|
63
|
+
options: ContainOptions,
|
|
64
|
+
): ((event: TEvent) => Promise<void>) => {
|
|
65
|
+
const report = (message: string) => {
|
|
66
|
+
try {
|
|
67
|
+
options.onContained?.(message)
|
|
68
|
+
} catch {
|
|
69
|
+
// A diagnostic sink that throws must not resurrect the failure.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return async (event: TEvent): Promise<void> => {
|
|
74
|
+
try {
|
|
75
|
+
await handler(event)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (options.allowBlocked && isBlocked(error)) throw error
|
|
78
|
+
report(`${options.label}: ${describe(error)}`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Run work against a deadline, resolving to `undefined` when it is exceeded.
|
|
85
|
+
*
|
|
86
|
+
* `Promise.race` would leave the slow work running and unobserved, which can
|
|
87
|
+
* produce an unhandled rejection later and, worse, keep doing work whose result
|
|
88
|
+
* nobody will read. This variant attaches handlers to the original promise so a
|
|
89
|
+
* late rejection is swallowed deliberately, and reports the timeout so the
|
|
90
|
+
* owner can see that routing was skipped.
|
|
91
|
+
*
|
|
92
|
+
* The timer is cleared on the normal path: a deadline that has already been met
|
|
93
|
+
* must not keep the process alive or fire against a later request.
|
|
94
|
+
*/
|
|
95
|
+
export const withDeadline = async <T>(input: {
|
|
96
|
+
readonly work: () => Promise<T> | T
|
|
97
|
+
readonly ms: number
|
|
98
|
+
readonly onTimeout: () => void
|
|
99
|
+
}): Promise<T | undefined> => {
|
|
100
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
101
|
+
let settled = false
|
|
102
|
+
|
|
103
|
+
const deadline = new Promise<undefined>((resolve) => {
|
|
104
|
+
timer = setTimeout(() => {
|
|
105
|
+
settled = true
|
|
106
|
+
try {
|
|
107
|
+
input.onTimeout()
|
|
108
|
+
} catch {
|
|
109
|
+
// Reporting the timeout must not replace it with another failure.
|
|
110
|
+
}
|
|
111
|
+
resolve(undefined)
|
|
112
|
+
}, input.ms)
|
|
113
|
+
// Never hold the event loop open on a deadline alone.
|
|
114
|
+
;(timer as any)?.unref?.()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const work = Promise.resolve()
|
|
118
|
+
.then(input.work)
|
|
119
|
+
.catch((error) => {
|
|
120
|
+
// The caller's handler is responsible for the failure; this only stops a
|
|
121
|
+
// rejection from escaping after the deadline has already won the race.
|
|
122
|
+
if (settled) return undefined
|
|
123
|
+
throw error
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
return (await Promise.race([work, deadline])) as T | undefined
|
|
128
|
+
} finally {
|
|
129
|
+
if (timer) clearTimeout(timer)
|
|
130
|
+
}
|
|
131
|
+
}
|