Skip to content

Commit 89e84c5

Browse files
committed
fix(browser): address post-impl code review findings
Fixes 4 issues identified in the 2026-04-07 post-implementation code review. See .tlc/tracks/adopt-lightpanda/reviews/ for the full report. C-1 (critical, blocker) — src/browser/resolver.js:409 resolveProbeOnly crashed with 'Cannot read properties of null' when stepLocalProbe returned null for a downloadable entry (lightpanda) with no local install. Triggered on clean systems with BROWSER_CHANNEL=lightpanda via the back-compat shim path. Fix: null-guard + return {} (matches pre-subsystem behavior — no local install, caller falls through to bundled chromium). Regression test added in resolver.chain.test.js covering both 'lightpanda' and 'panda' (alias). I-1 (important) — src/browser/downloader.js:239 Empty-body error path called ws.close() (graceful flush) instead of ws.destroy() (immediate), and was missing fsp.unlink(partialPath) for the orphan cleanup that the pipeline-failure path at 306 already does. Made the two error paths consistent. I-2 (important) — src/server.js:107 + new ContextPool.replaceBrowser() Direct mutation of pool._browser during lightpanda restart was reaching into private state and had an undocumented race window against in-flight _allocate() calls. Added ContextPool.replaceBrowser(browser) with explicit race contract in the JSDoc: in-flight calls holding the old reference will error, _allocate's catch block releases the slot, client gets a retryable error. This is the correct outcome — the old browser is dead; no safe way to recover in-flight calls. server.js now calls pool.replaceBrowser() instead of mutating the private field directly. I-3 (important) — src/browser/providers/github.js:108 Asset name matching used .includes(expected) which would false-positive on variants like 'lightpanda-aarch64-macos-debug' if present before the main asset in upload order. Fix: prefer exact match, fall back to extension-suffix match (expected + '.') for future archived formats (.tar.gz, .zip). Maintains compatibility with current bare-binary releases. Tests: 943 passing (+1 regression test), 0 failures. Track: adopt-lightpanda Review: .tlc/tracks/adopt-lightpanda/reviews/2026-04-07-post-impl-code-review.md
1 parent e9976ea commit 89e84c5

6 files changed

Lines changed: 61 additions & 5 deletions

File tree

src/browser/downloader.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,13 @@ export async function download(
237237
// Node's fetch() exposes a web ReadableStream on body; convert to a node stream.
238238
const body = res.body;
239239
if (!body) {
240-
ws.close();
240+
// destroy (not close) to release fd immediately; unlink orphan .partial
241+
ws.destroy();
242+
try {
243+
await fsp.unlink(partialPath);
244+
} catch {
245+
// ignore — orphan cleanup is best-effort
246+
}
241247
throw new Error(`download: empty response body for ${assetUrl}`);
242248
}
243249

src/browser/providers/github.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,17 @@ function findBinaryAsset(release, channelEntry, platform, arch) {
105105
}
106106
const expected = substituteAssetPattern(pattern, platform, arch);
107107
const assets = release.assets || [];
108-
const match = assets.find((a) => a.name && a.name.includes(expected));
108+
109+
// Prefer exact match to avoid false-positives on variants like
110+
// `lightpanda-aarch64-macos-debug` or `...-signed`. Fall back to an
111+
// extension-suffix match (`<expected>.tar.gz`, `.zip`, etc.) so the
112+
// resolver still works if upstream starts shipping archived binaries.
113+
const exact = assets.find((a) => a.name === expected);
114+
const extSuffix = exact
115+
? null
116+
: assets.find((a) => a.name && a.name.startsWith(expected + '.'));
117+
const match = exact ?? extSuffix;
118+
109119
if (!match) {
110120
const names = assets.map((a) => a.name).join(', ') || '(none)';
111121
throw new Error(

src/browser/resolver.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,11 @@ export function resolveProbeOnly(channelRaw) {
407407
if (!channelRaw) return {};
408408
const channelId = canonicalizeChannel(channelRaw);
409409
const record = stepLocalProbe(channelId);
410+
// stepLocalProbe returns null for downloadable entries (e.g. lightpanda)
411+
// on probe miss — the shim treats that as "not locally installed" and
412+
// returns the legacy empty shape so callers fall through to bundled
413+
// chromium, matching pre-subsystem behavior.
414+
if (!record) return {};
410415
const out = {};
411416
if (record.channel) out.channel = record.channel;
412417
if (record.executablePath) out.executablePath = record.executablePath;

src/server.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,11 @@ function attachDisconnectHandler(browserConfig) {
102102
browserHandle = newHandle;
103103
browser = newHandle.browser;
104104
if (pool) {
105-
// ContextPool stores the browser as `_browser`; mutate it so new
106-
// checkouts see the fresh browser without re-creating the pool.
107-
pool._browser = browser;
105+
// Swap the browser reference. In-flight _allocate() calls against
106+
// the dead browser will throw; ContextPool catches, decrements
107+
// active, and the client gets a retryable error. See
108+
// ContextPool#replaceBrowser for the race contract.
109+
pool.replaceBrowser(browser);
108110
}
109111
attachDisconnectHandler(browserConfig);
110112
return;

src/server/ContextPool.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,26 @@ export class ContextPool {
125125
logger.debug('ContextPool drained', { active: this._active });
126126
}
127127

128+
/**
129+
* Swap the underlying browser after a lifecycle event (e.g. lightpanda
130+
* crash + restart). Future checkouts and future _allocate() calls see
131+
* the new browser immediately.
132+
*
133+
* **Race contract (intentional):** any in-flight _allocate() that has
134+
* already read the previous `_browser` reference will complete its
135+
* newContext() call against the dead browser and throw. _allocate()'s
136+
* catch block decrements `_active` on failure, so the slot is released
137+
* and the client receives a retryable error. This is the correct
138+
* outcome — in-flight calls were made against a browser that no longer
139+
* exists; retrying is the only safe action.
140+
*
141+
* @param {import('playwright').Browser} browser - fresh browser instance
142+
*/
143+
replaceBrowser(browser) {
144+
this._browser = browser;
145+
logger.debug('ContextPool: browser replaced', { active: this._active });
146+
}
147+
128148
get activeCount() { return this._active; }
129149
get queueDepth() { return this._queue.length; }
130150

test/unit/browser/resolver.chain.test.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,19 @@ describe('resolveProbeOnly', () => {
141141
existsSpy.mockRestore();
142142
}
143143
});
144+
145+
// Regression for post-impl review finding C-1: resolveProbeOnly used to
146+
// crash with "Cannot read properties of null" when stepLocalProbe returned
147+
// null for a downloadable entry (lightpanda) with no local install.
148+
it('returns {} for downloadable channel with no local probe hit', () => {
149+
const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(false);
150+
try {
151+
expect(resolveProbeOnly('lightpanda')).toEqual({});
152+
expect(resolveProbeOnly('panda')).toEqual({}); // alias
153+
} finally {
154+
existsSpy.mockRestore();
155+
}
156+
});
144157
});
145158

146159
// ── emitResolved event shape ─────────────────────────────────────────────────

0 commit comments

Comments
 (0)