Skip to content

Commit 4121dd9

Browse files
authored
Retry test VS Code acquisition and propagate failures (#14752)
* Retry test VS Code acquisition and propagate failures * Normalize compile-and-test workflow line endings
1 parent 945b05b commit 4121dd9

5 files changed

Lines changed: 301 additions & 57 deletions

File tree

.github/workflows/job-compile-and-test.yml

Lines changed: 53 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -25,56 +25,56 @@ jobs:
2525
runs-on: ${{ inputs.runner-env }}
2626

2727
steps:
28-
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
28+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
2929
with:
3030
ref: ${{ inputs.checkout-ref }}
3131

3232
- name: Use Node.js 24
33-
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
33+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
3434
with:
3535
node-version: 24
3636

37-
- name: Validate Yarn lockfile
38-
run: yarn test-yarn-lock && yarn verify-yarn-lock
39-
working-directory: Extension
40-
41-
- name: Validate LLDB-MI component manifest
42-
run: yarn test-lldb-mi-component-manifest && yarn verify-lldb-mi-component-manifest
43-
working-directory: Extension
44-
37+
- name: Validate Yarn lockfile
38+
run: yarn test-yarn-lock && yarn verify-yarn-lock
39+
working-directory: Extension
40+
41+
- name: Validate LLDB-MI component manifest
42+
run: yarn test-lldb-mi-component-manifest && yarn verify-lldb-mi-component-manifest
43+
working-directory: Extension
44+
4545
- name: Install Dependencies
46-
shell: bash
47-
env:
48-
YARN_ARGS: ${{ inputs.yarn-args }}
49-
run: |
50-
read -r -a yarn_args <<< "$YARN_ARGS"
51-
for attempt in 1 2 3; do
52-
if yarn install "${yarn_args[@]}"; then
53-
exit 0
54-
fi
55-
if (( attempt == 3 )); then
56-
exit 1
57-
fi
58-
delay=$((attempt * 15))
59-
printf 'yarn install failed; retrying in %d seconds.\n' "$delay" >&2
60-
sleep "$delay"
61-
done
46+
shell: bash
47+
env:
48+
YARN_ARGS: ${{ inputs.yarn-args }}
49+
run: |
50+
read -r -a yarn_args <<< "$YARN_ARGS"
51+
for attempt in 1 2 3; do
52+
if yarn install "${yarn_args[@]}"; then
53+
exit 0
54+
fi
55+
if (( attempt == 3 )); then
56+
exit 1
57+
fi
58+
delay=$((attempt * 15))
59+
printf 'yarn install failed; retrying in %d seconds.\n' "$delay" >&2
60+
sleep "$delay"
61+
done
6262
working-directory: Extension
6363

6464
- name: Install gdb (linux)
6565
if: ${{ inputs.platform == 'linux' }}
66-
timeout-minutes: 10
66+
timeout-minutes: 10
6767
run: |
68-
sudo apt-get \
69-
-o Acquire::Retries=3 \
70-
-o Acquire::http::Timeout=30 \
71-
-o Acquire::https::Timeout=30 \
72-
update
73-
sudo apt-get \
74-
-o Acquire::Retries=3 \
75-
-o Acquire::http::Timeout=30 \
76-
-o Acquire::https::Timeout=30 \
77-
install -y gdb
68+
sudo apt-get \
69+
-o Acquire::Retries=3 \
70+
-o Acquire::http::Timeout=30 \
71+
-o Acquire::https::Timeout=30 \
72+
update
73+
sudo apt-get \
74+
-o Acquire::Retries=3 \
75+
-o Acquire::http::Timeout=30 \
76+
-o Acquire::https::Timeout=30 \
77+
install -y gdb
7878
7979
- name: Compile Sources
8080
run: yarn run compile
@@ -88,6 +88,10 @@ jobs:
8888
run: yarn test
8989
working-directory: Extension
9090

91+
- name: Test VS Code acquisition
92+
run: yarn test-vscode-acquisition
93+
working-directory: Extension
94+
9195
- name: Acquire Native Binaries
9296
run: yarn install-and-copy-binaries-for-test
9397
working-directory: Extension
@@ -97,11 +101,11 @@ jobs:
97101
run: yarn test --scenario=SingleRootProject
98102
working-directory: Extension
99103

100-
- name: Run SimpleCppProject tests (Windows)
101-
if: ${{ inputs.platform == 'windows' }}
102-
run: yarn test --scenario=SimpleCppProject
103-
working-directory: Extension
104-
104+
- name: Run SimpleCppProject tests (Windows)
105+
if: ${{ inputs.platform == 'windows' }}
106+
run: yarn test --scenario=SimpleCppProject
107+
working-directory: Extension
108+
105109
- name: Run E2E IntelliSense features tests (Windows)
106110
if: ${{ inputs.platform == 'windows' }}
107111
run: yarn test --scenario=MultirootDeadlockTest
@@ -122,13 +126,13 @@ jobs:
122126
run: yarn test --scenario=SingleRootProject
123127
working-directory: Extension
124128

125-
- name: Run SimpleCppProject tests (linux/macOS)
126-
if: ${{ inputs.platform == 'mac' || inputs.platform == 'linux' }}
127-
uses: coactions/setup-xvfb@b6b4fcfb9f5a895edadc3bc76318fae0ac17c8b3 # v1.0.1
128-
with:
129-
run: yarn test --scenario=SimpleCppProject
130-
working-directory: Extension
131-
129+
- name: Run SimpleCppProject tests (linux/macOS)
130+
if: ${{ inputs.platform == 'mac' || inputs.platform == 'linux' }}
131+
uses: coactions/setup-xvfb@b6b4fcfb9f5a895edadc3bc76318fae0ac17c8b3 # v1.0.1
132+
with:
133+
run: yarn test --scenario=SimpleCppProject
134+
working-directory: Extension
135+
132136
- name: Run E2E IntelliSense features tests (linux/macOS)
133137
if: ${{ inputs.platform == 'mac' || inputs.platform == 'linux' }}
134138
uses: coactions/setup-xvfb@b6b4fcfb9f5a895edadc3bc76318fae0ac17c8b3 # v1.0.1

Extension/.scripts/installAndCopyBinaries.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,13 @@
66
import { runVSCodeCommand } from '@vscode/test-electron';
77
import { writeFile } from 'node:fs/promises';
88
import { join } from 'node:path';
9-
import { $root, error, heading, note, warn } from './common';
9+
import { $root, heading, note, warn } from './common';
1010
import * as copy from './copyExtensionBinaries';
1111
import { install, isolated, options } from "./vscode";
1212

1313
export async function main() {
1414
console.log(heading(`Install VS Code`));
1515
const vscode = await install();
16-
if (!vscode) {
17-
error('Failed to install VS Code');
18-
return;
19-
}
2016

2117
console.log(heading('Install latest C/C++ Extension'));
2218
const result = await runVSCodeCommand([...vscode.args ?? [], '--install-extension', 'ms-vscode.cpptools', '--pre-release'], options);

Extension/.scripts/vscode.test.mjs

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import assert from 'node:assert/strict';
2+
import { spawnSync } from 'node:child_process';
3+
import { mkdtempSync, rmSync } from 'node:fs';
4+
import { createRequire } from 'node:module';
5+
import { tmpdir } from 'node:os';
6+
import { join } from 'node:path';
7+
import process from 'node:process';
8+
import test from 'node:test';
9+
import { fileURLToPath, URL } from 'node:url';
10+
11+
const require = createRequire(import.meta.url);
12+
const extensionRoot = fileURLToPath(new URL('../', import.meta.url));
13+
14+
require('ts-node').register({
15+
project: fileURLToPath(new URL('tsconfig.json', import.meta.url)),
16+
transpileOnly: true
17+
});
18+
const proxyquire = require('proxyquire').noCallThru();
19+
const sinon = require('sinon');
20+
const { TimeoutError } = require('@vscode/test-electron/out/request');
21+
22+
function networkError(code) {
23+
return Object.assign(new Error('Controlled acquisition failure'), { code });
24+
}
25+
26+
function createInstaller() {
27+
const download = sinon.stub().resolves('test-executable');
28+
const resolveCli = sinon.stub().returns(['test-cli', '--existing-argument', '--extensions-dir=default', '--user-data-dir=default']);
29+
const wait = sinon.stub().resolves();
30+
const mkdir = sinon.stub().resolves();
31+
const write = sinon.stub().resolves();
32+
const warn = sinon.stub();
33+
const installer = proxyquire(fileURLToPath(new URL('vscode.ts', import.meta.url)), {
34+
'@vscode/test-electron': { downloadAndUnzipVSCode: download, resolveCliArgsFromVSCodeExecutablePath: resolveCli },
35+
'timers/promises': { setTimeout: wait },
36+
'../src/Utility/Text/streams': { verbose: sinon.stub() },
37+
'./common': { mkdir, write, warn, readJson: sinon.stub().resolves({}) },
38+
'./vscodeTestPath': { getVSCodeTestIsolate: () => join(tmpdir(), 'cpptools-acquisition-unit') }
39+
});
40+
return { ...installer, download, resolveCli, wait, mkdir, write, warn };
41+
}
42+
43+
test('successful acquisition preserves the version, cache and CLI arguments without retries', async () => {
44+
const installer = createInstaller();
45+
const result = await installer.install();
46+
47+
assert.equal(installer.download.callCount, 1);
48+
assert.equal(installer.download.firstCall.args[0], installer.options);
49+
assert.equal(installer.options.version, installer.testVSCodeVersion);
50+
assert.equal(installer.options.cachePath, `${installer.isolated}/cache`);
51+
assert.deepEqual(result, {
52+
cli: 'test-cli',
53+
args: ['--existing-argument', `--extensions-dir=${installer.extensionsDir}`, `--user-data-dir=${installer.userDir}`]
54+
});
55+
assert.equal(installer.wait.callCount, 0);
56+
assert.equal(installer.warn.callCount, 0);
57+
assert.equal(installer.write.callCount, 1);
58+
});
59+
60+
for (const code of ['EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'EPIPE', 'ETIMEDOUT']) {
61+
test(`retries ${code} once before a successful acquisition`, async () => {
62+
const installer = createInstaller();
63+
installer.download.onFirstCall().rejects(networkError(code));
64+
65+
await installer.install();
66+
67+
assert.equal(installer.download.callCount, 2);
68+
assert.ok(installer.download.getCalls().every(call => call.args[0] === installer.options));
69+
assert.deepEqual(installer.wait.args, [[1000]]);
70+
assert.equal(installer.warn.callCount, 1);
71+
assert.equal(installer.mkdir.callCount, 1);
72+
assert.equal(installer.write.callCount, 1);
73+
});
74+
}
75+
76+
test('retries the test-electron request timeout', async () => {
77+
const installer = createInstaller();
78+
installer.download.onFirstCall().rejects(new TimeoutError(15000));
79+
80+
await installer.install();
81+
82+
assert.equal(installer.download.callCount, 2);
83+
assert.deepEqual(installer.wait.args, [[1000]]);
84+
});
85+
86+
test('exhausts transient aggregate errors after three attempts with bounded backoff', async () => {
87+
const installer = createInstaller();
88+
installer.download.rejects(new AggregateError([networkError('ETIMEDOUT'), networkError('ENETUNREACH')]));
89+
90+
await assert.rejects(installer.install(), /after 3 attempts: ETIMEDOUT:.*ENETUNREACH:/);
91+
92+
assert.equal(installer.download.callCount, 3);
93+
assert.deepEqual(installer.wait.args, [[1000], [2000]]);
94+
assert.equal(installer.warn.callCount, 2);
95+
assert.equal(installer.resolveCli.callCount, 0);
96+
assert.equal(installer.write.callCount, 0);
97+
});
98+
99+
for (const [name, failure] of [
100+
['an invalid version', new Error('Invalid version')],
101+
['a permissions error', networkError('EACCES')],
102+
['a full disk', networkError('ENOSPC')],
103+
['a certificate error', networkError('CERT_HAS_EXPIRED')],
104+
['the library exhausting its archive retries', new Error('Failed to download and unzip VS Code 1.131.0')],
105+
['an unclassified HTTP failure', 'Failed to get JSON'],
106+
['an empty aggregate error', new AggregateError([])],
107+
['an aggregate containing a permanent error', new AggregateError([networkError('ETIMEDOUT'), networkError('EACCES')])]
108+
]) {
109+
test(`does not retry ${name}`, async () => {
110+
const installer = createInstaller();
111+
installer.download.callsFake(async () => { throw failure; });
112+
113+
await assert.rejects(installer.install(), /Failed to install VS Code:.*after 1 attempt/);
114+
115+
assert.equal(installer.download.callCount, 1);
116+
assert.equal(installer.wait.callCount, 0);
117+
assert.equal(installer.warn.callCount, 0);
118+
assert.equal(installer.resolveCli.callCount, 0);
119+
assert.equal(installer.write.callCount, 0);
120+
});
121+
}
122+
123+
test('does not retry installation work after acquisition succeeds', async () => {
124+
const installer = createInstaller();
125+
installer.write.rejects(networkError('EPIPE'));
126+
127+
await assert.rejects(installer.install(), /Failed to install VS Code/);
128+
129+
assert.equal(installer.download.callCount, 1);
130+
assert.equal(installer.write.callCount, 1);
131+
assert.equal(installer.wait.callCount, 0);
132+
});
133+
134+
for (const [name, code, attempts, delays] of [
135+
['a non-retryable failure', 'EACCES', 1, []],
136+
['exhausted transient failures', 'ETIMEDOUT', 3, [1000, 2000]]
137+
]) {
138+
test(`acquisition CLI exits 1 without downstream work after ${name}`, () => {
139+
const testRoot = mkdtempSync(join(tmpdir(), 'cpptools-acquisition-'));
140+
const preload = `
141+
import { EventEmitter } from 'node:events';
142+
import { createRequire } from 'node:module';
143+
import process from 'node:process';
144+
const require = createRequire(${JSON.stringify(import.meta.url)});
145+
require('https').get = () => {
146+
process.stdout.write('ACQUISITION_ATTEMPT\\n');
147+
const request = new EventEmitter();
148+
request.destroy = () => request;
149+
process.nextTick(() => {
150+
const failure = Object.assign(new Error('Controlled VS Code acquisition failure'), { code: '${code}' });
151+
request.emit('error', '${code}' === 'ETIMEDOUT'
152+
? new AggregateError([failure, Object.assign(new Error('Controlled IPv6 failure'), { code: 'ENETUNREACH' })])
153+
: failure);
154+
});
155+
return request;
156+
};
157+
require('timers/promises').setTimeout = async (milliseconds) => {
158+
process.stdout.write('ACQUISITION_DELAY:' + milliseconds + '\\n');
159+
};
160+
const electronPath = require.resolve('@vscode/test-electron');
161+
const electron = require(electronPath);
162+
require.cache[electronPath].exports = {
163+
...electron,
164+
async runVSCodeCommand() {
165+
throw new Error('Unexpected extension installation');
166+
}
167+
};
168+
const copyPath = require.resolve('./copyExtensionBinaries.ts');
169+
require.cache[copyPath] = {
170+
id: copyPath,
171+
filename: copyPath,
172+
loaded: true,
173+
exports: {
174+
async main() {
175+
throw new Error('Unexpected binary copying');
176+
}
177+
}
178+
};
179+
`;
180+
181+
try {
182+
const result = spawnSync(process.execPath, [
183+
'--import', `data:text/javascript,${encodeURIComponent(preload)}`,
184+
require.resolve('ts-node/dist/bin.js'), '-T', '.scripts/installAndCopyBinaries.ts'
185+
], {
186+
cwd: extensionRoot,
187+
env: { ...process.env, CPPTOOLS_VSCODE_TEST_ROOT: testRoot },
188+
encoding: 'utf8',
189+
timeout: 15000
190+
});
191+
192+
assert.ifError(result.error);
193+
assert.equal(result.signal, null);
194+
const output = result.stdout + result.stderr;
195+
assert.match(output, /Controlled VS Code acquisition failure/);
196+
assert.match(output, new RegExp(`acquisition failed after ${attempts} attempt`));
197+
assert.equal(output.match(/^ACQUISITION_ATTEMPT$/gm)?.length, attempts);
198+
assert.deepEqual([...output.matchAll(/^ACQUISITION_DELAY:(\d+)$/gm)].map(match => Number(match[1])), delays);
199+
assert.doesNotMatch(output, /Install latest C\/C\+\+ Extension|Unexpected extension installation|Unexpected binary copying/);
200+
assert.equal(result.status, 1, output);
201+
} finally {
202+
rmSync(testRoot, { recursive: true, force: true });
203+
}
204+
});
205+
}

0 commit comments

Comments
 (0)