@actions/cache: downloadCacheHttpClientConcurrent never stops its progress timer on failure, so the step hangs until the job times out

Aperta Adatta ai principianti
#2,509 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

@YodHeVauHe ci sta già lavorando.

Dal 4/10/2026.

  • #1 di @YodHeVauHe — aperta
  • #2531 di @YodHeVauHe — aperta

Valutazione

Difficoltà
2/5
Tempo stimato
1-3 ore
Idoneità per principianti
82/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
typescript
Ambito
tooling

Direzione di ricerca

Inizia da packages/cache/src/internal/downloadUtils.ts, leggendo downloadCacheHttpClientConcurrent intorno al timer di avanzamento e al relativo blocco finally; confronta la relativa pulizia con downloadCacheStorageSDK. Esegui i comandi npm e repro.mjs forniti, quindi conferma che un download rifiutato interrompa l'output dell'avanzamento e consenta al processo di terminare.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

Describe the bug

When downloadCacheHttpClientConcurrent throws, it never stops its DownloadProgress display timer. The timer reschedules itself every second until isDone(), which a failed download never reaches. So Node's event loop never empties, the calling action finishes its work, and the step prints Received 0 of N (0.0%), 0.0 MBs/sec until the job times out or someone cancels it.

The timer starts here:

https://github.com/actions/toolkit/blob/85d7331b2bfbb4a135803df662f00069e9388f27/packages/cache/src/internal/downloadUtils.ts#L261-L262

This is the only cleanup, and it doesn't touch progress:

https://github.com/actions/toolkit/blob/85d7331b2bfbb4a135803df662f00069e9388f27/packages/cache/src/internal/downloadUtils.ts#L296-L299

The timer re-arms itself on every tick until the download completes:

https://github.com/actions/toolkit/blob/85d7331b2bfbb4a135803df662f00069e9388f27/packages/cache/src/internal/downloadUtils.ts#L135-L145

downloadCacheStorageSDK already stops its timer in finally:

https://github.com/actions/toolkit/blob/85d7331b2bfbb4a135803df662f00069e9388f27/packages/cache/src/internal/downloadUtils.ts#L441-L444

This is related to #2470 but has a different cause. #2470 is the promiseWithTimeout timer, which holds the process for at most 10 minutes. This timer has no upper bound, and #2478 doesn't change it.

Observed in CI

actions/setup-node@v7 (bundles @actions/cache 6.1.0) and tailscale/github-action v4.2.0 (bundles 5.0.2), on third-party runners (Ubicloud). Each 4 MiB ranged GET got a 161-byte body back, so the first archiveDescriptor.write threw. The action logged the warning and carried on, but the step never exited.

setup-node: the step logged the failure and moved on, then kept printing progress for 15 minutes until the job timeout:

10:58:59.536 /home/runner/.pnpm-store/v11
10:59:00.391 ##[warning]Failed to restore: The value of "length" is out of range. It must be <= 161. Received 4194304
10:59:00.404 pnpm cache is not found
10:59:00.793 Received 58720256 of 436400958 (13.5%), 55.9 MBs/sec
...           (the same byte count, once a second)
11:13:46.847 Received 58720256 of 436400958 (13.5%), 0.1 MBs/sec
11:13:46.919 ##[error]The operation was canceled.

tailscale: the action fell back to a direct download, joined the tailnet and finished at 12:24:50. The step then printed progress until the 5-minute step timeout:

12:24:45.188 ##[warning]Failed to restore: The value of "length" is out of range. It must be <= 161. Received 4194304
12:24:46.005 Received 0 of 34760007 (0.0%), 0.0 MBs/sec
12:24:50.613  Ping host ... reachable via direct connection!
...           (Received 0 of 34760007, once a second)
12:29:44.556 ##[error]The action 'Join tailnet' has timed out after 5 minutes.

Another job without a step timeout stayed in Setup Node.js for over an hour before it was cancelled.

The trigger here was a misbehaving cache backend, which I'm raising with the provider separately. The hang doesn't depend on the trigger: any rejection inside the try block, including a failed write, a failed segment or a HEAD without Content-Length after the timer started, leaves the timer running.

To Reproduce

This uses the published 6.3.0 package against a local server that answers every ranged GET with a short 200:

npm i @actions/[email protected]
timeout 300 node repro.mjs; echo "exit=$?"
// repro.mjs
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { downloadCacheHttpClientConcurrent } from './node_modules/@actions/cache/lib/internal/downloadUtils.js';

const LENGTH = 34760007;
const server = http.createServer((req, res) => {
  if (req.method === 'HEAD') {
    res.writeHead(200, { 'Content-Length': LENGTH });
    return res.end();
  }
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ error: 'x'.repeat(161 - 12) }));
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const url = `http://127.0.0.1:${server.address().port}/cache.tzst`;

try {
  await downloadCacheHttpClientConcurrent(url, path.join(os.tmpdir(), 'repro.tzst'), {
    timeoutInMs: 30000,
    downloadConcurrency: 8,
  });
} catch (error) {
  console.log(`rejected: ${error.message}`);
}
server.close();
console.log('download settled, server closed');

Output on Node 24.18.0:

rejected: The value of "length" is out of range. It must be <= 161. Received 4194304
download settled, server closed
Received 0 of 34760007 (0.0%), 0.0 MBs/sec
Received 0 of 34760007 (0.0%), 0.0 MBs/sec
...
exit=124

In this run it printed 299 progress lines after the rejection before timeout killed it at 300 s.

Expected behavior

Once the download settles, whether it succeeded or failed, the display timer stops and the process can exit.

Suggested fix

Hoist progress out of the try and stop it in finally, as downloadCacheStorageSDK already does:

   const archiveDescriptor = await fs.promises.open(archivePath, 'w')
   const httpClient = new HttpClient('actions/cache', undefined, {
     socketTimeout: options.timeoutInMs,
     keepAlive: true
   })
+  let progress: DownloadProgress | undefined
   try {
     ...
-    const progress = new DownloadProgress(length)
+    progress = new DownloadProgress(length)
     progress.startDisplayTimer()
     ...
   } finally {
+    progress?.stopDisplayTimer()
     httpClient.dispose()
     await archiveDescriptor.close()
   }

I applied the equivalent change to the compiled 6.3.0 output and ran the repro against it:

Build Process exits after the download rejects
6.3.0 as published never (killed at 300 s)
+ the change above 80 s
+ the change above and #2478 61 s

What keeps the process alive for the remaining ~60 s: segment downloads that were already in flight when the write threw keep retrying (downloadSegmentRetry, 5 retries, each with a 5 s retryHttpClientResponse backoff). Each attempt fails with Client has already been disposed. Nothing cancels them when the download is abandoned. That's secondary, bounded, and probably worth its own change. The display timer is the part that never ends.

Additional context

  • @actions/cache 6.3.0 is affected, and so is main at 85d7331. So are the copies bundled in actions/setup-node@v7 (6.1.0) and tailscale/github-action v4.2.0 (5.0.2).
  • Linux, Node 24.
  • This path is used only when the archive hostname ends in .blob.core.windows.net (see #2487).
Lingua principale
TypeScript
Stelle
5.9k
Fork
1.8k
Merge medio
3g 11h
PR unite (30g)
7

Preparare l'ambiente

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di actions/toolkit

Tutte le issue di actions/toolkit

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.