fix(huggingface-transformers): close HFT concurrency race (refcount TOCTOU + AbortController key collision)#637
Merged
Merged
Conversation
…ine lookup Nineteen HFT run-fns awaited getPipeline() and only then acquired the refcount via withHftPipelineInUse. Between the two awaits the refcount is zero, so enforcePipelineCacheCap in a sibling loader can pick the just-returned entry as LRU-non-in-use and call model.dispose() — the subsequent inference invokes a disposed ONNX/WASM session. HFT_Chat.ts already had the correct pattern (refcount before load). Move getPipeline inside withHftPipelineInUse in all 19 run-fns; no signature changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YY8Dv6snTc9maxiV5sbHwF
…ler to survive concurrent loads modelAbortControllers was keyed on bare model_path; two concurrent getPipeline calls for the same model but different pipeline/dtype/device (different cacheKey, so pipelineLoadPromises dedupe misses) both called .set(modelPath, ...), overwriting the first controller. The first caller's abort then fired on an orphaned controller; abortableFetch also used substring pathname match (Xenova/bge-reranker-base collided with Xenova/bge-reranker-base-v2). Switch to Map<string, Set<AbortController>>; combine matching signals via AbortSignal.any (with fallback); use segment-based pathname match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YY8Dv6snTc9maxiV5sbHwF
…d H2 (AbortController key) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YY8Dv6snTc9maxiV5sbHwF
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two high-severity concurrency bugs surfaced during the HFT pipeline hardening in #634. Both are latent under enough parallel load that they were not caught by the eviction tests added there.
H1 — Refcount-after-lookup TOCTOU (19 run-fn files)
Every HFT run-fn except
HFT_Chat.tsawaitedgetPipeline()first and then acquired the in-use refcount:Between the two awaits the refcount for the just-returned entry is zero. A sibling loader that pushes the cache over
MAX_CACHED_PIPELINESand runsenforcePipelineCacheCapcan pick that entry as an LRU-non-in-use candidate and callmodel.dispose(). The subsequent inference then invokes a disposed ONNX/WASM session, crashing the worker or (worse) reading whatever memory the WASM runtime handed back.HFT_Chat.tsalready had the correct order (refcount, then load) — used as the model.Fix: move
getPipelineinsidewithHftPipelineInUsein all 19 run-fns. No signature changes. Zero-shot / regular branches inHFT_TextClassification,HFT_ImageClassification,HFT_ObjectDetectionare each wrapped independently. Loggerpipeline readylines inHFT_TextEmbeddingandHFT_ImageEmbeddingmoved inside the callback so the log stays truthful.H2 —
modelAbortControllerskey collision (HFT_Pipeline.ts)modelAbortControllerswas aMap<string, AbortController>keyed on baremodel_path. Two concurrentgetPipelinecalls for the same model but differentpipeline/dtype/devicecombinations produce differentcacheKeys — the load dedupe inpipelineLoadPromisesmisses, so both loads run to completion. Both called.set(modelPath, ...), which overwrote the first controller. The first caller'sabort()then fired on an orphaned controller and never reached the in-flight fetch, so cancelling a load did nothing.Compounding the problem,
abortableFetchused a substring pathname match (pathname.includes(/${modelPath}/)), soXenova/bge-reranker-basecollided withXenova/bge-reranker-base-v2— aborting one would cancel the other's fetches.Fix:
Map<string, Set<AbortController>>keyed bymodel_path; concurrent loads coexist under one key.abortableFetchcollects every matching controller's signal and combines them viaAbortSignal.any(...)(falls back to the first match when unavailable).pathMatchesModelSegment(pathname, modelPath)does segment-based matching so-v2does not matchbase.removeModelController(modelPath, controller)helper replaces both.delete(modelPath)sites (early-abort branch +finally), and deletes the map entry when its set becomes empty soabortableFetchdoesn't walk stale keys.Test plan
packages/test/src/test/ai-provider-hft/HFT_Pipeline.race.test.ts— H1 regression:packages/test/src/test/ai-provider-hft/HFT_AbortControllers.test.ts— H2 regression:model_pathdo not orphan anAbortController(set size 2 → 1 → 0 with key cleanup), andpathMatchesModelSegmenttable: rejects substring collisions (Xenova/bge-reranker-base-v2vsXenova/bge-reranker-base,foobertvsbert) and accepts full-segment matches.bun scripts/test.ts provider-hft vitest— all 59 vitest tests pass (5 skipped are pre-existing integration skips).bun run build:types— all 40 TS build tasks succeed.modelAbortControllersandpathMatchesModelSegmentare exported with@internalJSDoc fromHFT_Pipeline.tsfor the test to reach (mirroring the existingpipelinesexport convention).Context
Follows up #634, which introduced the bounded-LRU pipeline cache + refcount system that these bugs sit inside. The refcount plumbing (
withHftPipelineInUse,isHftPipelineInUse,removeCachedPipelinein-use skip) was already in place — only the ordering at the call sites was wrong for H1, and the map value type / key comparator was wrong for H2.Commits
fix(huggingface-transformers): hold pipeline refcount before getPipeline lookup(19 run-fns)fix(huggingface-transformers): key model AbortControllers per-controller to survive concurrent loads(HFT_Pipeline.ts)test(huggingface-transformers): regressions for H1 (refcount race) and H2 (AbortController key)Generated with Claude Code
https://claude.ai/code/session_01YY8Dv6snTc9maxiV5sbHwF
Generated by Claude Code