Skip to content

Commit 6867354

Browse files
authored
Fix unresolved streaming cache review comments
1 parent 31b74e9 commit 6867354

5 files changed

Lines changed: 123 additions & 17 deletions

File tree

libraries/rush-lib/src/logic/buildCache/ICloudBuildCacheProvider.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ export interface ICloudBuildCacheProvider {
1818
* cache entry into memory, if possible. The implementation should download the cache entry and write it
1919
* to the specified local file path.
2020
*
21-
* @returns `true` if the cache entry was found and written to the file, `false` if it was
22-
* not found. Throws on errors.
21+
* @returns `true` if the cache entry was found and written to the file; otherwise `false`.
22+
* Implementations typically log transfer failures and return `false`, but may still throw for
23+
* unexpected errors.
2324
*/
2425
tryDownloadCacheEntryToFileAsync?(
2526
terminal: ITerminal,

libraries/rush-lib/src/utilities/WebClient.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ function _makeRawRequestAsync<TResponse>(
170170

171171
handleResponse(response, redirected, resolve, reject);
172172
}).on('error', (error: Error) => {
173+
if (body && !Buffer.isBuffer(body)) {
174+
body.destroy(error);
175+
}
176+
173177
reject(error);
174178
});
175179

libraries/rush-lib/src/utilities/test/WebClient.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
22
// See LICENSE in the project root for license information.
33

4+
import { createServer, type Server } from 'node:http';
5+
import { Readable } from 'node:stream';
6+
47
import { WebClient } from '../WebClient';
58

69
describe(WebClient.name, () => {
@@ -52,4 +55,40 @@ describe(WebClient.name, () => {
5255
expect(target).toMatchSnapshot();
5356
});
5457
});
58+
59+
describe(WebClient.prototype.fetchAsync.name, () => {
60+
it('destroys a streamed request body if the request errors', async () => {
61+
const server: Server = createServer((request) => {
62+
request.socket.destroy();
63+
});
64+
await new Promise<void>((resolve, reject) => {
65+
server.once('error', reject);
66+
server.listen(0, '127.0.0.1', () => resolve());
67+
});
68+
69+
const address = server.address();
70+
if (!address || typeof address === 'string') {
71+
throw new Error('Expected a TCP server address');
72+
}
73+
74+
const webClient: WebClient = new WebClient();
75+
const body: Readable = new Readable({
76+
read() {
77+
this.push(Buffer.alloc(64 * 1024));
78+
}
79+
});
80+
81+
await expect(
82+
webClient.fetchAsync(`http://127.0.0.1:${address.port}`, {
83+
verb: 'PUT',
84+
body
85+
})
86+
).rejects.toThrow();
87+
expect(body.destroyed).toBe(true);
88+
89+
await new Promise<void>((resolve, reject) => {
90+
server.close((error) => (error ? reject(error) : resolve()));
91+
});
92+
});
93+
});
5594
});

rush-plugins/rush-amazon-s3-build-cache-plugin/src/AmazonS3Client.ts

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ const storageRetryOptions: IStorageRetryOptions = {
7070
retryPolicyType: StorageRetryPolicyType.EXPONENTIAL
7171
};
7272

73+
function _isWebClientResponse(response: IWebClientResponseBase): response is IWebClientResponse {
74+
const candidate: Partial<IWebClientResponse> = response;
75+
return (
76+
typeof candidate.getTextAsync === 'function' &&
77+
typeof candidate.getJsonAsync === 'function' &&
78+
typeof candidate.getBufferAsync === 'function'
79+
);
80+
}
81+
7382
/**
7483
* Computes the SHA-256 hash of a file on disk using streaming reads.
7584
*/
@@ -216,6 +225,7 @@ export class AmazonS3Client {
216225

217226
// Compute SHA-256 hash of the file before uploading so we can sign the payload
218227
const contentHash: string = await _hashFileAsync(localFilePath);
228+
const { size } = await FileSystem.getStatisticsAsync(localFilePath);
219229
const entryStream: FileSystemReadStream = FileSystem.createReadStream(localFilePath);
220230

221231
// Streaming uploads cannot be retried because the stream is consumed after the first attempt.
@@ -224,7 +234,8 @@ export class AmazonS3Client {
224234
objectName,
225235
entryStream as Readable,
226236
true,
227-
contentHash
237+
contentHash,
238+
size
228239
);
229240
response.stream.resume();
230241
if (!response.ok) {
@@ -260,7 +271,7 @@ export class AmazonS3Client {
260271
getSuccessResult: () => T | Promise<T>,
261272
cleanup?: () => void
262273
): Promise<RetryableRequestResponse<T | undefined>> {
263-
const { ok, status, statusText } = response;
274+
const { ok, status } = response;
264275
if (ok) {
265276
return {
266277
hasNetworkError: false,
@@ -288,16 +299,25 @@ export class AmazonS3Client {
288299
};
289300
} else if (status === 400 || status === 401 || status === 403) {
290301
cleanup?.();
291-
throw new Error(`Amazon S3 responded with status code ${status} (${statusText})`);
302+
throw await this._getGetResponseErrorAsync(response);
292303
} else {
293304
cleanup?.();
294305
return {
295306
hasNetworkError: true,
296-
error: new Error(`Amazon S3 responded with status code ${status} (${statusText})`)
307+
error: await this._getGetResponseErrorAsync(response)
297308
};
298309
}
299310
}
300311

312+
private async _getGetResponseErrorAsync(response: IWebClientResponseBase): Promise<Error> {
313+
if (_isWebClientResponse(response)) {
314+
return await this._getS3ErrorAsync(response);
315+
}
316+
317+
const { status, statusText } = response;
318+
return new Error(`Amazon S3 responded with status code ${status} (${statusText})`);
319+
}
320+
301321
private async _makeSignedRequestAsync(
302322
verb: 'GET' | 'PUT',
303323
objectName: string,
@@ -308,27 +328,33 @@ export class AmazonS3Client {
308328
objectName: string,
309329
body: Readable | undefined,
310330
stream: true,
311-
contentHash?: string
331+
contentHash?: string,
332+
contentLength?: number
312333
): Promise<IWebClientStreamResponse>;
313334
private async _makeSignedRequestAsync(
314335
verb: 'GET' | 'PUT',
315336
objectName: string,
316337
body?: Buffer | Readable,
317338
stream?: boolean,
318-
contentHash?: string
339+
contentHash?: string,
340+
contentLength?: number
319341
): Promise<IWebClientResponse | IWebClientStreamResponse> {
320342
// Use the provided content hash if available (e.g. pre-computed from a file on disk),
321343
// otherwise compute from the buffer body, or use the empty hash for GET requests.
322344
const bodyHash: string = contentHash ?? this._getBufferSha256(Buffer.isBuffer(body) ? body : undefined);
323-
const { url, headers } = this._buildSignedRequest(verb, objectName, bodyHash);
345+
const { url, headers } = this._buildSignedRequest(verb, objectName, bodyHash, contentLength);
324346

325-
const webFetchOptions: IGetFetchOptions | IFetchOptionsWithBody = {
326-
verb,
327-
headers
328-
};
329-
if (verb === 'PUT' && body) {
330-
(webFetchOptions as IFetchOptionsWithBody).body = body;
331-
}
347+
const webFetchOptions: IGetFetchOptions | IFetchOptionsWithBody =
348+
verb === 'GET'
349+
? {
350+
verb: 'GET',
351+
headers
352+
}
353+
: {
354+
verb: 'PUT',
355+
headers,
356+
body
357+
};
332358

333359
if (stream) {
334360
return await this._webClient.fetchStreamAsync(url, webFetchOptions);
@@ -343,12 +369,16 @@ export class AmazonS3Client {
343369
private _buildSignedRequest(
344370
verb: 'GET' | 'PUT',
345371
objectName: string,
346-
bodyHash: string
372+
bodyHash: string,
373+
contentLength?: number
347374
): { url: string; headers: Record<string, string> } {
348375
const isoDateString: IIsoDateString = this._getIsoDateString();
349376
const headers: Record<string, string> = {};
350377
headers[DATE_HEADER_NAME] = isoDateString.dateTime;
351378
headers[CONTENT_HASH_HEADER_NAME] = bodyHash;
379+
if (verb === 'PUT' && contentLength !== undefined) {
380+
headers['Content-Length'] = `${contentLength}`;
381+
}
352382

353383
// the host can be e.g. https://s3.aws.com or http://localhost:9000
354384
const host: string = this._s3Endpoint.replace(protocolRegex, '');

rush-plugins/rush-amazon-s3-build-cache-plugin/src/test/AmazonS3Client.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,9 @@ describe(AmazonS3Client.name, () => {
661661
jest
662662
.spyOn(FileSystem, 'createWriteStreamAsync')
663663
.mockResolvedValue({} as unknown as Awaited<ReturnType<typeof FileSystem.createWriteStreamAsync>>);
664+
jest
665+
.spyOn(FileSystem, 'getStatisticsAsync')
666+
.mockResolvedValue({ size: 123 } as Awaited<ReturnType<typeof FileSystem.getStatisticsAsync>>);
664667
// Return a Readable that immediately ends, so _hashFileAsync completes with the null hash
665668
jest.spyOn(FileSystem, 'createReadStream').mockReturnValue(
666669
new Readable({
@@ -836,6 +839,7 @@ describe(AmazonS3Client.name, () => {
836839
const [url, options] = spy.mock.calls[0];
837840
expect(url).toBe('http://localhost:9000/abc123');
838841
expect(options.verb).toBe('PUT');
842+
expect(options.headers['Content-Length']).toBe('123');
839843
// Verify the content hash is a real SHA-256 hex string, NOT UNSIGNED-PAYLOAD
840844
expect(options.headers['x-amz-content-sha256']).toMatch(/^[0-9a-f]{64}$/);
841845
expect(options.headers['x-amz-date']).toBe('20200418T123242Z');
@@ -875,6 +879,34 @@ describe(AmazonS3Client.name, () => {
875879
expect(spy).toHaveBeenCalledTimes(1);
876880
spy.mockRestore();
877881
});
882+
883+
it('Preserves S3 error details for buffered GET failures', async () => {
884+
jest.spyOn(WebClient.prototype, 'fetchAsync').mockReturnValue(
885+
Promise.resolve({
886+
getBufferAsync: () => Promise.resolve(Buffer.from('AccessDenied: missing permission')),
887+
getTextAsync: () => Promise.resolve('AccessDenied: missing permission'),
888+
getJsonAsync: () => Promise.reject(new Error('Not JSON')),
889+
headers: {},
890+
status: 403,
891+
statusText: 'Forbidden',
892+
ok: false,
893+
redirected: false
894+
})
895+
);
896+
897+
const s3Client: AmazonS3Client = new AmazonS3Client(
898+
{
899+
accessKeyId: 'accessKeyId',
900+
secretAccessKey: 'secretAccessKey',
901+
sessionToken: undefined
902+
},
903+
DUMMY_OPTIONS,
904+
webClient,
905+
terminal
906+
);
907+
908+
await expect(s3Client.getObjectAsync('abc123')).rejects.toThrow('AccessDenied: missing permission');
909+
});
878910
});
879911
});
880912
});

0 commit comments

Comments
 (0)