Skip to content

Commit c6546c7

Browse files
hugomonteroclaude
andcommitted
Reuse the toolchain manifest for 12 hours so offline compiles exit
Offline on a network without internet, the manifest request timed out and the compile used the cache, but the aborted request's DNS lookup stayed pending and the CLI could not exit after "Saved firmware". A cached manifest younger than 12 h (Workbench's TTL) is now used with no request; a 304 renews it, and a platform or --target the cached copy does not know fetches anyway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1b1e19e commit c6546c7

4 files changed

Lines changed: 72 additions & 5 deletions

File tree

‎src/lib/toolchain/local-compiler.js‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ class LocalCompiler {
4040
* @returns {Promise<{ toolchain: object, dependencies: object[], platform: object }>}
4141
*/
4242
async resolve({ version, platformId, platformName }) {
43-
const manifest = await this.fetchManifest();
43+
let manifest = await this.fetchManifest();
44+
if (!this._knows(manifest, { version, platformId, platformName })) {
45+
// the cached copy may predate a new Device OS release or platform
46+
manifest = await this.fetchManifest({ maxAgeMs: 0 });
47+
}
4448
const platform = manifest.platform(platformId);
4549
if (!platform) {
4650
throw new Error(`The local toolchain does not support ${platformName}; use --compiler cloud`);
@@ -230,6 +234,18 @@ class LocalCompiler {
230234
return path.join(this.installer.rootFor(tools), declared || 'bin/bash.exe');
231235
}
232236

237+
_knows(manifest, { version, platformId, platformName }) {
238+
if (!manifest.platform(platformId)) {
239+
return false;
240+
}
241+
try {
242+
manifest.resolveToolchain({ version, platformId, platformName });
243+
return true;
244+
} catch (_error) {
245+
return false;
246+
}
247+
}
248+
233249
_write(message) {
234250
if (!this.ui.quiet) {
235251
this.ui.write(message);

‎src/lib/toolchain/local-compiler.test.js‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,25 @@ describe('Local compiler', () => {
8080
expect(installer.ensureInstalled).to.have.been.calledWith(dependencies, { label: 'Local toolchain for Device OS 6.4.1 (argon)' });
8181
});
8282

83+
it('trusts the cached manifest when it knows the requested version', async () => {
84+
const fetchManifest = sinon.stub().resolves(manifest);
85+
compiler.fetchManifest = fetchManifest;
86+
await compiler.resolve({ version: '6.4.1', platformId: 12, platformName: 'argon' });
87+
expect(fetchManifest).to.have.been.calledOnceWithExactly();
88+
});
89+
90+
it('asks the server again when the cached manifest predates the requested version', async () => {
91+
const data = await fs.readJson(path.join(PATH_FIXTURES_DIR, 'toolchain', 'manifest.json'));
92+
const stale = new ToolchainManifest({ ...data, toolchains: data.toolchains.filter(t => t.version !== '6.4.1') });
93+
const fetchManifest = sinon.stub();
94+
fetchManifest.onFirstCall().resolves(stale);
95+
fetchManifest.onSecondCall().resolves(manifest);
96+
compiler.fetchManifest = fetchManifest;
97+
const { toolchain } = await compiler.resolve({ version: '6.4.1', platformId: 12, platformName: 'argon' });
98+
expect(toolchain.version).to.equal('6.4.1');
99+
expect(fetchManifest.secondCall).to.have.been.calledWithExactly({ maxAgeMs: 0 });
100+
});
101+
83102
it('rejects a platform the manifest does not know', async () => {
84103
await expect(compiler.resolve({ platformId: 42, platformName: 'tachyon' }))
85104
.to.be.rejectedWith('The local toolchain does not support tachyon; use --compiler cloud');

‎src/lib/toolchain/manifest.js‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ const ParticleCache = require('../particle-cache');
88
const MANIFEST_URL = 'https://binaries.particle.io/toolchain-manager/manifest.json';
99
const MANIFEST_CACHE_KEY = 'toolchain-manifest';
1010
const MANIFEST_TIMEOUT_MS = 4000;
11+
// Workbench's TTL. Within it a local compile makes no network request: offline, an aborted
12+
// request would still leave its DNS lookup pending and keep the CLI from exiting.
13+
const MANIFEST_MAX_AGE_MS = 12 * 60 * 60 * 1000;
1114

1215
// The dependency kinds a local compile needs, in the order the environment is
1316
// built from them. `debuggers` (openocd) is deliberately left out.
@@ -116,22 +119,27 @@ class ToolchainManifest {
116119
}
117120

118121
/**
119-
* Fetches the manifest, using the cached copy when the server says it is
120-
* unchanged or cannot be reached. Throws only when there is neither.
122+
* Fetches the manifest, using the cached copy when it is younger than `maxAgeMs`,
123+
* or when the server says it is unchanged or cannot be reached. Throws only when there is neither.
121124
* @param {object} [opts]
122125
* @param {ParticleCache} [opts.cache]
123126
* @param {string} [opts.url]
124127
* @param {number} [opts.timeoutMs]
128+
* @param {number} [opts.maxAgeMs] 0 always asks the server
125129
* @returns {Promise<ToolchainManifest>}
126130
*/
127-
async function fetchManifest({ cache = new ParticleCache(), url = MANIFEST_URL, timeoutMs = MANIFEST_TIMEOUT_MS } = {}) {
131+
async function fetchManifest({ cache = new ParticleCache(), url = MANIFEST_URL, timeoutMs = MANIFEST_TIMEOUT_MS, maxAgeMs = MANIFEST_MAX_AGE_MS } = {}) {
128132
const cached = cache.get(MANIFEST_CACHE_KEY);
133+
if (cached && cached.fetchedAt && Date.now() - cached.fetchedAt < maxAgeMs) {
134+
return new ToolchainManifest(cached.data);
135+
}
129136
const headers = cached && cached.etag ? { 'If-None-Match': cached.etag } : {};
130137
const controller = new AbortController();
131138
const timeout = setTimeout(() => controller.abort(), timeoutMs);
132139
try {
133140
const response = await fetch(url, { headers, signal: controller.signal });
134141
if (response.status === 304 && cached) {
142+
cache.set(MANIFEST_CACHE_KEY, { ...cached, fetchedAt: Date.now() });
135143
return new ToolchainManifest(cached.data);
136144
}
137145
if (!response.ok) {
@@ -154,6 +162,7 @@ async function fetchManifest({ cache = new ParticleCache(), url = MANIFEST_URL,
154162
module.exports = {
155163
MANIFEST_URL,
156164
MANIFEST_CACHE_KEY,
165+
MANIFEST_MAX_AGE_MS,
157166
DEPENDENCY_KINDS,
158167
ToolchainManifest,
159168
fetchManifest,

‎src/lib/toolchain/manifest.test.js‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ const {
1111
hostFor,
1212
displayName,
1313
MANIFEST_URL,
14-
MANIFEST_CACHE_KEY
14+
MANIFEST_CACHE_KEY,
15+
MANIFEST_MAX_AGE_MS
1516
} = require('./manifest');
1617

1718
const FIXTURE = path.join(PATH_FIXTURES_DIR, 'toolchain', 'manifest.json');
@@ -143,6 +144,28 @@ describe('Toolchain manifest', () => {
143144
expect(manifest.toolchainForVersion('2.3.1')).to.be.an('object');
144145
});
145146

147+
it('uses a cache younger than 12 hours without a request', async () => {
148+
cache.set(MANIFEST_CACHE_KEY, { etag: '"abc"', data: json, fetchedAt: Date.now() - MANIFEST_MAX_AGE_MS + 60000 });
149+
const manifest = await fetchManifest({ cache });
150+
expect(manifest.toolchainForVersion('6.4.1')).to.be.an('object');
151+
expect(nock.isDone()).to.equal(true);
152+
});
153+
154+
it('asks the server once the cache is older than 12 hours, and a 304 renews it', async () => {
155+
cache.set(MANIFEST_CACHE_KEY, { etag: '"abc"', data: json, fetchedAt: Date.now() - MANIFEST_MAX_AGE_MS - 1 });
156+
const scope = nock('https://binaries.particle.io').get('/toolchain-manager/manifest.json').reply(304);
157+
await fetchManifest({ cache });
158+
expect(scope.isDone()).to.equal(true);
159+
expect(Date.now() - cache.get(MANIFEST_CACHE_KEY).fetchedAt).to.be.below(5000);
160+
});
161+
162+
it('asks the server despite a fresh cache when maxAgeMs is 0', async () => {
163+
cache.set(MANIFEST_CACHE_KEY, { etag: '"abc"', data: json, fetchedAt: Date.now() });
164+
const scope = nock('https://binaries.particle.io').get('/toolchain-manager/manifest.json').reply(304);
165+
await fetchManifest({ cache, maxAgeMs: 0 });
166+
expect(scope.isDone()).to.equal(true);
167+
});
168+
146169
it('falls back to the cache when offline', async () => {
147170
cache.set(MANIFEST_CACHE_KEY, { etag: '"abc"', data: json });
148171
nock('https://binaries.particle.io').get('/toolchain-manager/manifest.json').replyWithError('ENOTFOUND');

0 commit comments

Comments
 (0)