fix(browse): refuse a runtime-root CLI and checkout server from different builds (#3026)

A Windows runtime root runs the source checkout's Node server bundle through
.source-path. When the root's CLI and the checkout's bundle carry different
browse/dist/.version builds (the checkout was rebuilt without refreshing the
root), the CLI now refuses to start the server with both builds, the
./setup fix and its troubleshooting anchor. Unversioned dev builds are not
called skew.

Co-authored-by: ubay887 <ubay887@users.noreply.github.com>
This commit is contained in:
garrytanandubay887 committed 2026-10-06 20:33:56 +00:00
1 parent c9766e3540
commit e1ceb5fcf4
3 files changed
+67 -4

No files matched your search

+21 -4
View File
@@ -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
+30
View File
@@ -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() {')));
+16
View File
@@ -582,6 +582,22 @@ hooks stay.
## Browser
<a id="browse-runtime-version-skew"></a>
### `[browse] this install's browse CLI (<root>, build <hash>) and the gstack checkout's server bundle (<checkout>, build <hash>) 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 <checkout> && ./setup
```
<a id="browse-chain-no-flow"></a>
### `[browse] chain: no flow to run (stdin was empty)` (or `stdin is a terminal`, `stdin could not be read (EAGAIN)`)