@gmod/bam 7.8.2 → 7.9.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/src/bamFile.ts CHANGED
@@ -7,7 +7,7 @@ import CSI from './csi.ts'
7
7
  import NullFilehandle from './nullFilehandle.ts'
8
8
  import BAMFeature from './record.ts'
9
9
  import { parseHeaderText } from './sam.ts'
10
- import { appendInRange, parseRefSeqs } from './util.ts'
10
+ import { appendInRange, parseRefSeqs, throwIfAborted } from './util.ts'
11
11
 
12
12
  import type Chunk from './chunk.ts'
13
13
  import type { BamOpts, BaseOpts } from './util.ts'
@@ -37,10 +37,13 @@ export const BAM_MAGIC = 21840194
37
37
 
38
38
  const blockLen = 1 << 16
39
39
 
40
- // Ceiling on the header read. A million contigs is roughly 10MB of compressed
41
- // @SQ lines and ref-seq table, so anything past this is a corrupt header
42
- // claiming a huge n_ref rather than a real one, and growing the read further
43
- // just downloads the file.
40
+ // Ceiling on GROWING the header read. A million contigs is roughly 10MB of
41
+ // compressed @SQ lines and ref-seq table, so a read that has doubled past this
42
+ // is chasing a corrupt header claiming a huge n_ref rather than a real one, and
43
+ // growing further just downloads the file. It does not cap the FIRST read: that
44
+ // length comes from the index's firstDataLine, which is a real offset rather
45
+ // than a guess, so a header that genuinely runs past this still gets its one
46
+ // exact read. See getHeaderPre.
44
47
  const maxHeaderReadLen = 32 * 1024 * 1024
45
48
 
46
49
  function resolveFilehandle(
@@ -62,9 +65,16 @@ interface ChunkEntry<T> {
62
65
 
63
66
  interface InFlightChunk<T> {
64
67
  promise: Promise<ChunkEntry<T>>
65
- // the signal the read was started with, so a waiter can tell "the owner
66
- // aborted" apart from "the read genuinely failed"
67
- signal?: AbortSignal
68
+ // Signals of the callers still waiting on this read. The read is cancelled
69
+ // only once every one of them has given up — see joinChunkRead and ADR 0007.
70
+ signals: Set<AbortSignal>
71
+ // true once a caller joins without a signal, which pins the read
72
+ pinned: boolean
73
+ // aborts when every caller has given up. what the read actually runs under
74
+ controller: AbortController
75
+ // aborted to take this read's listeners back off its callers' signals
76
+ dispose: AbortController
77
+ settled: boolean
68
78
  }
69
79
 
70
80
  /**
@@ -118,12 +128,24 @@ export const DEFAULT_MAX_CACHE_BYTES = 100 * 1024 * 1024
118
128
  const MAX_CONCURRENT_CHUNK_READS = 6
119
129
 
120
130
  class ChunkFeatureCache<T> {
121
- public maxBytes: number
131
+ private _maxBytes: number
122
132
  private entries = new Map<string, ChunkEntry<T>>()
123
133
  private bytes = 0
124
134
 
125
135
  constructor(maxBytes: number) {
126
- this.maxBytes = maxBytes
136
+ this._maxBytes = maxBytes
137
+ }
138
+
139
+ get maxBytes() {
140
+ return this._maxBytes
141
+ }
142
+
143
+ // Accessor rather than a plain field so lowering the budget frees memory now.
144
+ // As a field, a caller trimming the cache under memory pressure got nothing
145
+ // back until the next chunk read happened to call set().
146
+ set maxBytes(maxBytes: number) {
147
+ this._maxBytes = maxBytes
148
+ this.evict()
127
149
  }
128
150
 
129
151
  get size() {
@@ -148,11 +170,15 @@ class ChunkFeatureCache<T> {
148
170
  this.delete(key)
149
171
  this.entries.set(key, entry)
150
172
  this.bytes += entry.bytes
151
- // Evict from the least-recently-used end. The size > 1 guard means a single
152
- // chunk larger than the whole budget is still kept: the caller needs it for
153
- // the query in flight, and dropping it would only force a re-decompress.
173
+ this.evict()
174
+ }
175
+
176
+ // Evict from the least-recently-used end. The size > 1 guard means a single
177
+ // chunk larger than the whole budget is still kept: the caller needs it for
178
+ // the query in flight, and dropping it would only force a re-decompress.
179
+ private evict() {
154
180
  const lru = this.entries.keys()
155
- while (this.bytes > this.maxBytes && this.entries.size > 1) {
181
+ while (this.bytes > this._maxBytes && this.entries.size > 1) {
156
182
  this.delete(lru.next().value!)
157
183
  }
158
184
  }
@@ -285,16 +311,21 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
285
311
  ? blockLen
286
312
  : indexData.firstDataLine.blockPosition + blockLen
287
313
 
314
+ // do/while, so the index-derived length is always read once and only the
315
+ // doubling is bounded. Testing readLen before the first read instead meant
316
+ // a BAM whose header genuinely exceeds maxHeaderReadLen — millions of
317
+ // contigs — was rejected without a single byte being fetched, and reported
318
+ // as 'Insufficient data for reference sequences' when the data was there.
288
319
  let samHeader
289
- let atEof = false
290
- while (samHeader === undefined && !atEof && readLen <= maxHeaderReadLen) {
320
+ let atEof: boolean
321
+ do {
291
322
  const buffer = await this.bam.read(readLen, 0, { signal: opts.signal })
292
323
  // a short read means readLen ran past the end of the file, so there are
293
324
  // no more bytes to grow into
294
325
  atEof = buffer.length < readLen
295
326
  samHeader = this.applyHeader(await unzip(buffer))
296
327
  readLen *= 2
297
- }
328
+ } while (samHeader === undefined && !atEof && readLen <= maxHeaderReadLen)
298
329
  if (samHeader === undefined) {
299
330
  throw new Error('Insufficient data for reference sequences')
300
331
  }
@@ -368,6 +399,7 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
368
399
  max: number,
369
400
  opts?: BamOpts,
370
401
  ) {
402
+ throwIfAborted(opts?.signal)
371
403
  const chrId = await this.getSeqId(chr, opts)
372
404
  if (chrId === undefined || !this.index) {
373
405
  return []
@@ -378,23 +410,85 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
378
410
 
379
411
  // Read a chunk, publish it to the cache, and keep the in-flight promise
380
412
  // discoverable while it runs.
381
- private _startChunkRead(cacheKey: string, chunk: Chunk, opts: BaseOpts) {
382
- const promise = this._readChunkFeatures(chunk, opts).then(entry => {
413
+ //
414
+ // The read runs under this entry's own controller rather than any one
415
+ // caller's signal, because the read is shared: it must survive until every
416
+ // caller waiting on it has given up. joinChunkRead is what registers them.
417
+ private _startChunkRead(cacheKey: string, chunk: Chunk) {
418
+ const controller = new AbortController()
419
+ const promise = this._readChunkFeatures(chunk, {
420
+ signal: controller.signal,
421
+ }).then(entry => {
383
422
  this.chunkFeatureCache.set(cacheKey, entry)
384
423
  return entry
385
424
  })
386
- const inFlight: InFlightChunk<T> = { promise, signal: opts.signal }
425
+ const inFlight: InFlightChunk<T> = {
426
+ promise,
427
+ signals: new Set(),
428
+ pinned: false,
429
+ controller,
430
+ dispose: new AbortController(),
431
+ settled: false,
432
+ }
387
433
  this.inFlightChunks.set(cacheKey, inFlight)
388
- // Only clear our own entry: a retry may already have replaced it. `.then(f,
389
- // f)` rather than `.finally(f)` so the handler's own promise never carries
390
- // an unhandled rejection.
434
+ // `.then(f, f)` rather than `.finally(f)` so the handler's own promise never
435
+ // carries an unhandled rejection.
391
436
  const clear = () => {
437
+ inFlight.settled = true
438
+ // nothing reads these once the read has settled, and holding them would
439
+ // pin each caller's AbortController behind this entry
440
+ inFlight.dispose.abort()
441
+ inFlight.signals.clear()
442
+ // only clear our own entry: a later read for this chunk may have replaced it
392
443
  if (this.inFlightChunks.get(cacheKey) === inFlight) {
393
444
  this.inFlightChunks.delete(cacheKey)
394
445
  }
395
446
  }
396
447
  promise.then(clear, clear)
397
- return promise
448
+ return inFlight
449
+ }
450
+
451
+ // Register a caller's interest, so the read survives until that caller has
452
+ // given up too.
453
+ //
454
+ // A caller with no signal cannot give up, so it pins the read: there is no
455
+ // longer any set of aborts that should stop it. That is the honest reading of
456
+ // a caller that never asked to be cancellable, and it means one signal-free
457
+ // query makes that chunk's read uncancellable for everyone joined to it.
458
+ private joinChunkRead(inFlight: InFlightChunk<T>, signal?: AbortSignal) {
459
+ if (signal === undefined) {
460
+ inFlight.pinned = true
461
+ } else if (signal.aborted) {
462
+ // A caller that has already given up is not a waiter, and must not be
463
+ // counted as one: an `abort` listener never fires on a signal that
464
+ // aborted before it was added, so nothing would ever take this signal
465
+ // back out of the set. The count would never reach zero and the read
466
+ // would be uncancellable for everyone joined to it, silently.
467
+ //
468
+ // `_cachedChunkFeatures` rejects such a caller before it reaches here,
469
+ // with no `await` in between, so this is unreachable today. It is here
470
+ // because this is the bug that shipped, and an invariant that fails this
471
+ // quietly should not rest on a check twenty lines away. `@gmod/cram`
472
+ // guards the same spot for the same reason; see its ADR 0003.
473
+ if (!inFlight.pinned && inFlight.signals.size === 0) {
474
+ inFlight.controller.abort(signal.reason)
475
+ }
476
+ } else if (!inFlight.signals.has(signal)) {
477
+ // guarded so one signal joining twice — a viewAsPairs query reaching the
478
+ // same chunk for a read and for its mate — does not add two listeners
479
+ inFlight.signals.add(signal)
480
+ signal.addEventListener(
481
+ 'abort',
482
+ () => {
483
+ inFlight.signals.delete(signal)
484
+ if (!inFlight.pinned && inFlight.signals.size === 0) {
485
+ inFlight.controller.abort(signal.reason)
486
+ }
487
+ },
488
+ // `once` covers the abort firing; `dispose` covers it never firing
489
+ { once: true, signal: inFlight.dispose.signal },
490
+ )
491
+ }
398
492
  }
399
493
 
400
494
  // Parsed records for a chunk, reading and decompressing it only on a miss.
@@ -411,6 +505,15 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
411
505
  chunk: Chunk,
412
506
  opts: BaseOpts,
413
507
  ): Promise<T[]> {
508
+ // Before anything else, including the cache hit. A caller reaches here with
509
+ // a signal that has already fired on the ordinary pan — the abort lands
510
+ // while blocksForRange is still reading the index, and nothing between
511
+ // there and here looks at it, since bai.ts and csi.ts never read the
512
+ // signal. Such a caller must not start a read it has no interest in, and
513
+ // must not be registered as a waiter on someone else's: see joinChunkRead.
514
+ // @gmod/cram checks in exactly this position, in SliceRecordCache.getOrFill.
515
+ throwIfAborted(opts.signal)
516
+
414
517
  const cacheKey = chunkCacheKey(chunk)
415
518
  const cached = this.chunkFeatureCache.get(cacheKey)
416
519
  if (cached) {
@@ -422,23 +525,35 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
422
525
  // they collapse onto very few chunk keys, so without this a query pays for
423
526
  // the same inflate several times over — the dominant cost of a cold query
424
527
  // (ADR 0003).
425
- const pending = this.inFlightChunks.get(cacheKey)
426
- if (!pending) {
427
- return (await this._startChunkRead(cacheKey, chunk, opts)).features
528
+ let pending = this.inFlightChunks.get(cacheKey)
529
+ // A read every caller has abandoned is on its way out but may not have
530
+ // noticed yet. Start a fresh one rather than joining one already doomed.
531
+ if (pending?.controller.signal.aborted && !pending.settled) {
532
+ if (this.inFlightChunks.get(cacheKey) === pending) {
533
+ this.inFlightChunks.delete(cacheKey)
534
+ }
535
+ pending = undefined
536
+ }
537
+ pending ??= this._startChunkRead(cacheKey, chunk)
538
+ // Only a read still running has anything to cancel. Joining a settled one
539
+ // would add this caller to a set nothing will ever take it out of, since
540
+ // the entry drops its abort listeners when it settles.
541
+ if (!pending.settled) {
542
+ this.joinChunkRead(pending, opts.signal)
428
543
  }
429
544
 
430
545
  try {
431
- return (await pending.promise).features
546
+ const entry = await pending.promise
547
+ // the read finished, but this caller gave up while waiting for it
548
+ throwIfAborted(opts.signal)
549
+ return entry.features
432
550
  } catch (e) {
433
- // The read we joined was started by another caller. If that caller
434
- // aborted and we did not, the failure is theirs and says nothing about
435
- // our query, so start over — which picks up the cache, joins a sibling's
436
- // retry, or reads under our own signal. Any other failure (and our own
437
- // abort) propagates as it would have without sharing.
438
- if (!pending.signal?.aborted || opts.signal?.aborted) {
439
- throw e
440
- }
441
- return this._cachedChunkFeatures(chunk, opts)
551
+ // Prefer this caller's own cancellation to whatever the shared read
552
+ // reported. If we asked to stop, that is the answer we want — and when
553
+ // the read itself was cancelled it is because we, and everyone else,
554
+ // asked it to.
555
+ throwIfAborted(opts.signal)
556
+ throw e
442
557
  }
443
558
  }
444
559
 
@@ -499,9 +614,7 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
499
614
  // unbarriered pool exactly as before. That caps the cost of being wrong
500
615
  // at 0.92x-0.95x, against 0.82x-0.88x for barriering every wave.
501
616
  const batch = Math.min(MAX_CONCURRENT_CHUNK_READS, chunks.length)
502
- await Promise.all(
503
- Array.from({ length: batch }, (_, ci) => readOne(ci)),
504
- )
617
+ await Promise.all(Array.from({ length: batch }, (_, ci) => readOne(ci)))
505
618
  let stopped = false
506
619
  for (let ci = 0; ci < batch; ci++) {
507
620
  if (isPastQuery(featureLists[ci], chrId, max)) {
@@ -615,9 +728,13 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
615
728
  const mateRecs = [] as T[]
616
729
  for (let i = 0, l = features.length; i < l; i++) {
617
730
  const feature = features[i]!
731
+ // fileOffset first: it is a number already on the record, where
732
+ // `name` decodes a string per record. A mate chunk usually overlaps
733
+ // the query region, so this skips the decode for every record the
734
+ // caller is already holding.
618
735
  if (
619
- readNameCounts.get(feature.name) === 1 &&
620
- !readIds.has(feature.fileOffset)
736
+ !readIds.has(feature.fileOffset) &&
737
+ readNameCounts.get(feature.name) === 1
621
738
  ) {
622
739
  mateRecs.push(feature)
623
740
  }
@@ -643,6 +760,21 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
643
760
  signal: opts.signal,
644
761
  },
645
762
  )
763
+ // The last chance to bail before the expensive part, and the reason it is
764
+ // worth having: honouring the signal is optional in the filehandle.
765
+ // `RemoteFile` hands it to `fetch`, but `LocalFile.read(length, position)`
766
+ // does not even take an options argument, so every read under it runs to
767
+ // completion and arrives here with the cancellation unnoticed. Without this
768
+ // a fully abandoned chunk still gets inflated and decoded — measured at 6
769
+ // chunks for one 20kb query on out.bam — which is the dominant cost of a
770
+ // cold query (ADR 0003), spent on records nobody will ever look at.
771
+ //
772
+ // Safe precisely because this is the SHARED signal, not a caller's: it
773
+ // fires only once every waiter has given up, so there is never a live
774
+ // caller left to be denied the result. @gmod/cram checks in the same place
775
+ // and for the same reason, before the decode loop in `_fetchRecords`.
776
+ throwIfAborted(opts.signal)
777
+
646
778
  const {
647
779
  buffer: data,
648
780
  cpositions,
@@ -691,9 +823,9 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
691
823
  blockEnd,
692
824
  hasCpositions
693
825
  ? cpositions[pos]! * (1 << 8) +
694
- (blockStart - dpositions[pos]!) +
695
- chunk.minv.dataPosition +
696
- 1
826
+ (blockStart - dpositions[pos]!) +
827
+ chunk.minv.dataPosition +
828
+ 1
697
829
  : crc32(ba.subarray(blockStart, blockEnd)) >>> 0,
698
830
  dataView,
699
831
  )
package/src/csi.ts CHANGED
@@ -96,6 +96,20 @@ export default class CSI extends IndexFile {
96
96
  this.maxBinNumber = ((1 << ((this.depth + 1) * 3)) - 1) / 7
97
97
  const maxBinNumber = this.maxBinNumber
98
98
  const auxLength = dataView.getInt32(12, true)
99
+ // A tabix-only branch, which is why parseAuxData and the parseNameBytes it
100
+ // calls show as uncovered. CSI is shared between `samtools index -c` and
101
+ // `tabix -C`, and the aux block is how a tabix index carries what a reader
102
+ // of bgzipped TEXT needs: which columns hold ref/start/end, the comment
103
+ // character, lines to skip, and the reference names — which a BAM takes
104
+ // from its own header instead. So a BAM .csi sets l_aux to 0 and never
105
+ // reaches here (checked: 0 of the 19 .csi fixtures carry one).
106
+ //
107
+ // Kept rather than deleted for two reasons. CSI is exported from
108
+ // index.ts, so pointing it at a tabix index is reachable (@gmod/tabix is
109
+ // the right tool, but this would half-work and then silently not).
110
+ // More importantly parseNameBytes is marked SYNC: with its tabix-js
111
+ // counterpart, and dropping one side of a deliberately-paired pair of
112
+ // implementations is exactly what that marker exists to prevent.
99
113
  const aux = auxLength >= 30 ? this.parseAuxData(bytes, 16) : undefined
100
114
  const refCount = dataView.getInt32(16 + auxLength, true)
101
115
 
@@ -200,6 +214,12 @@ export default class CSI extends IndexFile {
200
214
  for (; l <= this.depth; s -= 3, t += lshift(1, l * 3), l += 1) {
201
215
  const b = t + rshift(beg, s)
202
216
  const e = t + rshift(end, s)
217
+ // Unreachable as long as the clamp above stands, which is why it shows
218
+ // as uncovered: with end bounded to 2^(minShift + depth*3), level l
219
+ // spans at most 8^l - 1 bins, so the worst case is 8^depth - 1 + depth
220
+ // against a maxBinNumber of (8^(depth+1) - 1)/7, i.e. about 1.14 * 8^depth.
221
+ // Kept as a guard on the arithmetic rather than deleted — reg2bins is
222
+ // shared with tabix-js, whose callers reach it by other routes.
203
223
  if (e - b + bins.length > this.maxBinNumber) {
204
224
  throw new Error(
205
225
  `query ${beg}-${end} is too large for current binning scheme (shift ${this.minShift}, depth ${this.depth}), try a smaller query or a coarser index binning scheme`,
package/src/index.ts CHANGED
@@ -4,7 +4,12 @@ export { default as CSI } from './csi.ts'
4
4
  export { default as BamRecord } from './record.ts'
5
5
  export { default as HtsgetFile } from './htsget.ts'
6
6
 
7
- export type { Bytes } from './record.ts'
7
+ export type { NumericCigar } from './record.ts'
8
8
  export type { BamRecordClass, BamRecordLike } from './bamFile.ts'
9
+ // the options every query method takes, and the shapes they hand back. The
10
+ // package has no subpath exports, so a consumer typing a wrapper around
11
+ // getRecordsForRange/indexCov can only name these if they come out of here.
12
+ export type { BamOpts, BaseOpts } from './util.ts'
13
+ export type { IndexCovEntry } from './bai.ts'
9
14
  // for typing the HtsgetFile `fetch` option
10
15
  export type { Fetcher } from 'generic-filehandle2'
package/src/indexFile.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import QuickLRU from '@jbrowse/quick-lru'
2
2
 
3
- import { optimizeChunks } from './util.ts'
3
+ import { optimizeChunks, throwIfAborted } from './util.ts'
4
4
 
5
5
  import type Chunk from './chunk.ts'
6
6
  import type { BaseOpts } from './util.ts'
@@ -33,8 +33,11 @@ export function memoizeByRefId<T>(
33
33
  ) {
34
34
  const cache = new QuickLRU<number, T>({ maxSize })
35
35
  return (refId: number) => {
36
- if (cache.has(refId)) {
37
- return cache.get(refId)
36
+ // one lookup, not has()+get(): only truthy results are ever cached, so a
37
+ // miss and a cached value are already distinguishable
38
+ const cached = cache.get(refId)
39
+ if (cached !== undefined) {
40
+ return cached
38
41
  }
39
42
  const result = getIndices(refId)
40
43
  if (result) {
@@ -51,6 +54,13 @@ export default abstract class IndexFile<
51
54
  public renameRefSeq: (s: string) => string
52
55
 
53
56
  private setupP?: Promise<TParsed>
57
+ /**
58
+ * The signal `setupP` was started under, while it is still in flight. The
59
+ * index is parsed once and shared by every query against the file, so without
60
+ * this the first query to arrive would own a read all the others depend on —
61
+ * see {@link parse}.
62
+ */
63
+ private setupSignal?: AbortSignal
54
64
 
55
65
  constructor({
56
66
  filehandle,
@@ -118,14 +128,70 @@ export default abstract class IndexFile<
118
128
  return optimizeChunks(chunks, this.getLowestChunk(ba, min))
119
129
  }
120
130
 
121
- parse(opts: BaseOpts = {}): Promise<TParsed> {
122
- if (!this.setupP) {
123
- this.setupP = this._parse(opts).catch((e: unknown) => {
124
- this.setupP = undefined
131
+ /**
132
+ * Parse the index, or join the parse already running.
133
+ *
134
+ * The index is downloaded and parsed once for the life of this object, so it
135
+ * is the one read here that is shared between queries — and therefore the one
136
+ * place a cancellation can leak from the query that asked for it to a query
137
+ * that did not. `_parse` hands `opts` straight to `filehandle.readFile`, so
138
+ * without this the first query to arrive owns a read every other query
139
+ * depends on: when it pans away, every concurrent query fails with its abort.
140
+ *
141
+ * A caller that joined someone else's parse and saw it fail because *they*
142
+ * aborted starts over rather than inheriting the failure — once, then
143
+ * propagates. Bounding it at one attempt is what jbrowse's
144
+ * `RemoteFileWithRangeCache.joinChunk` does with the same retry one layer
145
+ * down, and for the reason it gives: the pathological case becomes one
146
+ * duplicate parse rather than a recursion whose depth depends on how the
147
+ * aborts interleave.
148
+ *
149
+ * A retry rather than the reference count `_cachedChunkFeatures` uses, for
150
+ * the reason `@gmod/cram` gives for the same split in `CraiIndex`: the index
151
+ * is parsed once for the life of the object, so there is no repeated waste to
152
+ * recover, and this is a dozen lines against restructuring the memo.
153
+ */
154
+ async parse(opts: BaseOpts = {}, retried = false): Promise<TParsed> {
155
+ throwIfAborted(opts.signal)
156
+ const pending = this.setupP
157
+ if (!pending) {
158
+ return this.startParse(opts)
159
+ }
160
+
161
+ // read before awaiting: the owner is forgotten as soon as the parse settles
162
+ const ownerSignal = this.setupSignal
163
+ try {
164
+ return await pending
165
+ } catch (e) {
166
+ if (retried || !ownerSignal?.aborted || opts.signal?.aborted) {
125
167
  throw e
126
- })
168
+ }
169
+ return this.parse(opts, true)
127
170
  }
128
- return this.setupP
171
+ }
172
+
173
+ private startParse(opts: BaseOpts) {
174
+ const pending = this._parse(opts)
175
+ this.setupP = pending
176
+ this.setupSignal = opts.signal
177
+ // Drop a rejection rather than keeping it, so one transient failure does not
178
+ // poison the index for the lifetime of the file. Both branches are
179
+ // identity-checked so a retry started after this settles is not cleared by
180
+ // the attempt it already replaced.
181
+ pending.then(
182
+ () => {
183
+ if (this.setupP === pending) {
184
+ this.setupSignal = undefined
185
+ }
186
+ },
187
+ () => {
188
+ if (this.setupP === pending) {
189
+ this.setupP = undefined
190
+ this.setupSignal = undefined
191
+ }
192
+ },
193
+ )
194
+ return pending
129
195
  }
130
196
 
131
197
  async lineCount(refId: number, opts?: BaseOpts) {