diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 7cdcc703e..d75881b15 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -121,18 +121,35 @@ function reachesPlaywright(script: string): boolean { * copies of browse/dist and no node_modules, so its server-node.mjs cannot * import playwright. setup records the source checkout in the root's * .source-path; run that checkout's bundle, which sits beside node_modules. + * Refuses (throws) when the two builds differ. */ function sourceServerScript(execPath: string): string | null { + const root = path.resolve(path.dirname(execPath), '..', '..'); + let source: string; try { - const root = path.resolve(path.dirname(execPath), '..', '..'); - const source = fs.readFileSync(path.join(root, '.source-path'), 'utf8').trim(); - const script = path.join(source, 'browse', 'dist', 'server-node.mjs'); - return path.isAbsolute(source) && fs.existsSync(script) && reachesPlaywright(script) ? script : null; + source = fs.readFileSync(path.join(root, '.source-path'), 'utf8').trim(); } catch { return null; } + const script = path.join(source, 'browse', 'dist', 'server-node.mjs'); + if (!path.isAbsolute(source) || !fs.existsSync(script) || !reachesPlaywright(script)) return null; + // The CLI here and the checkout's server must come from the same build + // (both write browse/dist/.version); a checkout rebuilt without refreshing + // this root would otherwise run a server its CLI does not match. + const cliVersion = readVersionHash(execPath); + const serverVersion = readVersionHash(script); + if (cliVersion && serverVersion && cliVersion !== serverVersion) { + throw new Error( + `this install's browse CLI (${root}, build ${cliVersion.slice(0, 12)}) and the gstack checkout's server bundle ` + + `(${source}, build ${serverVersion.slice(0, 12)}) are from different builds, so the server was not started. ` + + `Fix: cd "${source}" && ./setup (rebuilds and refreshes every runtime root). ${BROWSE_VERSION_SKEW_ANCHOR}`, + ); + } + return script; } +export const BROWSE_VERSION_SKEW_ANCHOR = 'https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#browse-runtime-version-skew'; + /** * Which server to start, resolved only when a server is actually started * (#2439). Windows runs the Node bundle and never needs server.ts, which a diff --git a/browse/test/config.test.ts b/browse/test/config.test.ts index 6d53aedc5..e5028a592 100644 --- a/browse/test/config.test.ts +++ b/browse/test/config.test.ts @@ -340,6 +340,36 @@ describe('#3026: a Windows runtime root without node_modules runs the source che } }); + test('same build on both sides -> the checkout bundle; different builds -> refusal with the ./setup fix and anchor', () => { + const { BROWSE_VERSION_SKEW_ANCHOR } = require('../src/cli'); + const l = layout({ runtimeModules: false, sourcePath: true, sourceModules: true }); + const version = (root: string, v: string) => fs.writeFileSync(path.join(root, 'browse', 'dist', '.version'), `${v}\n`); + try { + version(l.runtime, 'a'.repeat(40)); + version(l.source, 'a'.repeat(40)); + expect(resolveNodeServerScript('/$bunfs/root', l.exe)).toBe(path.join(l.source, 'browse', 'dist', 'server-node.mjs')); + version(l.source, 'b'.repeat(40)); + let message = ''; + try { resolveNodeServerScript('/$bunfs/root', l.exe); } catch (err: any) { message = err.message; } + expect(message).toContain(`build ${'a'.repeat(12)}`); + expect(message).toContain(`build ${'b'.repeat(12)}`); + expect(message).toContain('are from different builds, so the server was not started'); + expect(message).toContain(`Fix: cd "${l.source}" && ./setup`); + expect(message).toContain(BROWSE_VERSION_SKEW_ANCHOR); + expect(BROWSE_VERSION_SKEW_ANCHOR).toBe('https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#browse-runtime-version-skew'); + // resolveServerLaunch surfaces the same refusal on Windows. + expect(() => require('../src/cli').resolveServerLaunch('win32', {}, '/$bunfs/root', l.exe)).toThrow('different builds'); + } finally { fs.rmSync(l.base, { recursive: true, force: true }); } + }); + + test('an unversioned build on either side is not called skew', () => { + const l = layout({ runtimeModules: false, sourcePath: true, sourceModules: true }); + try { + fs.writeFileSync(path.join(l.runtime, 'browse', 'dist', '.version'), 'abc\n'); + expect(resolveNodeServerScript('/$bunfs/root', l.exe)).toBe(path.join(l.source, 'browse', 'dist', 'server-node.mjs')); + } finally { fs.rmSync(l.base, { recursive: true, force: true }); } + }); + test('setup records the source checkout in every runtime root it links', () => { const setup = fs.readFileSync(path.resolve(__dirname, '../../setup'), 'utf8'); const fn = setup.slice(setup.indexOf('_link_runtime_dists() {'), setup.indexOf('\n}\n', setup.indexOf('_link_runtime_dists() {'))); diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index cc74c25df..3c95e238f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -582,6 +582,22 @@ hooks stay. ## Browser + +### `[browse] this install's browse CLI (, build ) and the gstack checkout's server bundle (, build ) are from different builds, so the server was not started. ...` + +**Meaning.** On Windows a host runtime root (`~/.codex/skills/gstack` and the +other env-var hosts) holds a copy of browse with no `node_modules`, so its CLI +starts the server bundle in the gstack checkout recorded in `.source-path`. +The checkout was rebuilt (or updated) without refreshing this runtime root, so +the two builds differ and browse refuses rather than run a mismatched server. + +**Fix.** Re-run setup from the checkout named in the message; it rebuilds and +refreshes every runtime root: + +```bash +cd && ./setup +``` + ### `[browse] chain: no flow to run (stdin was empty)` (or `stdin is a terminal`, `stdin could not be read (EAGAIN)`)