@@ -7,6 +7,18 @@ import { dirname, join } from "path";
77import type { WriteStream } from "fs" ;
88import { createWriteStream , ensureDir } from "fs-extra" ;
99import { asError } from "./helpers-pure" ;
10+ import { createTimeoutSignal } from "./fetch-stream" ;
11+
12+ /**
13+ * Default idle timeout (in seconds) used when a caller does not specify one. If
14+ * no bytes are extracted and no files complete within this window, the
15+ * extraction is aborted. This is a safety net that guards against a single
16+ * stalled write hanging the whole operation forever. 30 seconds of zero
17+ * extraction progress is well beyond any healthy pause, so this won't trigger
18+ * false positives. (The CLI downloader passes the configurable
19+ * `codeQL.cli.downloadTimeout` instead of relying on this default.)
20+ */
21+ const DEFAULT_UNZIP_IDLE_TIMEOUT_SECONDS = 30 ;
1022
1123// We can't use promisify because it picks up the wrong overload.
1224export function openZip (
@@ -93,6 +105,7 @@ export async function copyStream(
93105 readable : Readable ,
94106 writeStream : WriteStream ,
95107 bytesExtractedCallback ?: ( bytesExtracted : number ) => void ,
108+ signal ?: AbortSignal ,
96109) : Promise < void > {
97110 await pipeline (
98111 readable ,
@@ -103,6 +116,7 @@ export async function copyStream(
103116 } ,
104117 } ) ,
105118 writeStream ,
119+ { signal } ,
106120 ) ;
107121}
108122
@@ -130,6 +144,7 @@ async function unzipFile(
130144 entry : ZipEntry ,
131145 rootDestinationPath : string ,
132146 bytesExtractedCallback ?: ( bytesExtracted : number ) => void ,
147+ signal ?: AbortSignal ,
133148) : Promise < number > {
134149 const path = join ( rootDestinationPath , entry . fileName ) ;
135150
@@ -155,7 +170,7 @@ async function unzipFile(
155170 mode,
156171 } ) ;
157172
158- await copyStream ( readable , writeStream , bytesExtractedCallback ) ;
173+ await copyStream ( readable , writeStream , bytesExtractedCallback , signal ) ;
159174
160175 return entry . uncompressedSize ;
161176 }
@@ -176,6 +191,7 @@ export async function unzipToDirectory(
176191 destinationPath : string ,
177192 progress : UnzipProgressCallback | undefined ,
178193 taskRunner : ( tasks : Array < ( ) => Promise < void > > ) => Promise < void > ,
194+ timeoutSeconds : number = DEFAULT_UNZIP_IDLE_TIMEOUT_SECONDS ,
179195) : Promise < void > {
180196 const zipFile = await openZip ( archivePath , {
181197 autoClose : false ,
@@ -202,28 +218,53 @@ export async function unzipToDirectory(
202218
203219 reportProgress ( ) ;
204220
205- await taskRunner (
206- entries . map ( ( entry ) => async ( ) => {
207- let entryBytesExtracted = 0 ;
208-
209- const totalEntryBytesExtracted = await unzipFile (
210- zipFile ,
211- entry ,
212- destinationPath ,
213- ( thisBytesExtracted ) => {
214- entryBytesExtracted += thisBytesExtracted ;
215- bytesExtracted += thisBytesExtracted ;
216- reportProgress ( ) ;
217- } ,
221+ // Abort extraction if no progress is made for `timeoutSeconds`. `pipeline`
222+ // only rejects on a stream error, so without this a single write that
223+ // blocks without erroring (e.g. slow/networked storage or security
224+ // software holding a file) would hang the extraction indefinitely.
225+ const { signal, onData, dispose } = createTimeoutSignal ( timeoutSeconds ) ;
226+
227+ try {
228+ await taskRunner (
229+ entries . map ( ( entry ) => async ( ) => {
230+ let entryBytesExtracted = 0 ;
231+
232+ const totalEntryBytesExtracted = await unzipFile (
233+ zipFile ,
234+ entry ,
235+ destinationPath ,
236+ ( thisBytesExtracted ) => {
237+ // Reset the idle timeout: we are making progress.
238+ onData ( ) ;
239+ entryBytesExtracted += thisBytesExtracted ;
240+ bytesExtracted += thisBytesExtracted ;
241+ reportProgress ( ) ;
242+ } ,
243+ signal ,
244+ ) ;
245+
246+ // Should be 0, but just in case.
247+ bytesExtracted += - entryBytesExtracted + totalEntryBytesExtracted ;
248+
249+ // Reset the idle timeout on completion too, so extracting many empty
250+ // files or directories doesn't trip the timeout.
251+ onData ( ) ;
252+ filesExtracted ++ ;
253+ reportProgress ( ) ;
254+ } ) ,
255+ ) ;
256+ } catch ( e ) {
257+ if ( signal . aborted ) {
258+ throw new Error (
259+ `Timed out while extracting archive: no progress was made for ${ timeoutSeconds } seconds. ` +
260+ "This can happen if a file cannot be written, for example because of slow or networked storage, " +
261+ "or security software holding a file open." ,
218262 ) ;
219-
220- // Should be 0, but just in case.
221- bytesExtracted += - entryBytesExtracted + totalEntryBytesExtracted ;
222-
223- filesExtracted ++ ;
224- reportProgress ( ) ;
225- } ) ,
226- ) ;
263+ }
264+ throw e ;
265+ } finally {
266+ dispose ( ) ;
267+ }
227268 } finally {
228269 zipFile . close ( ) ;
229270 }
@@ -242,6 +283,7 @@ export async function unzipToDirectorySequentially(
242283 archivePath : string ,
243284 destinationPath : string ,
244285 progress ?: UnzipProgressCallback ,
286+ timeoutSeconds ?: number ,
245287) : Promise < void > {
246288 return unzipToDirectory (
247289 archivePath ,
@@ -252,5 +294,6 @@ export async function unzipToDirectorySequentially(
252294 await task ( ) ;
253295 }
254296 } ,
297+ timeoutSeconds ,
255298 ) ;
256299}
0 commit comments