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)`)