Skip to content

Commit 316bfba

Browse files
authored
fix: detect and replace stale playground instances on target port (#56)
When upgrading the CLI (e.g. from stable to beta), a previously-started playground process may still occupy the default port. The new instance silently binds to the next port, but the browser opens the old one — leaving the user stuck on a stale UI. Changes: - playground /health now returns { playground: { version, pid } } so the CLI can identify what is running on the port - CLI probes the target port before spawning a new playground: - same version → reuse the existing instance, open browser, exit - different version → SIGTERM the old process, wait for port release, then start the new version - non-playground / no response → proceed normally (backward-compatible) - Add Cache-Control: no-cache to static assets served by the playground to prevent browsers caching stale JS/CSS across upgrades (filenames are stable, not content-hashed, due to console embed constraints) Change-Id: Ida50d15b60802fc8b56e13d2d9318c2cc077ad46 Co-developed-by: OpenCode <noreply@opencode.ai>
1 parent e537ab3 commit 316bfba

2 files changed

Lines changed: 97 additions & 3 deletions

File tree

packages/cli/src/commands/playground.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,54 @@ function openBrowser(url: string): void {
133133
}
134134
}
135135

136+
interface ExistingPlayground {
137+
version: string;
138+
pid: number;
139+
}
140+
141+
/**
142+
* Probe the target port for an already-running playground instance.
143+
* Returns version + pid when the enriched `/health` response is present,
144+
* `null` when the port is free or occupied by a non-playground process.
145+
*/
146+
async function probeExistingPlayground(port: number): Promise<ExistingPlayground | null> {
147+
try {
148+
const controller = new AbortController();
149+
const timeout = setTimeout(() => controller.abort(), 2000);
150+
const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
151+
clearTimeout(timeout);
152+
if (!res.ok) return null;
153+
const body = (await res.json()) as { playground?: { version?: string; pid?: number } };
154+
if (body.playground?.pid) {
155+
return { version: body.playground.version ?? "unknown", pid: body.playground.pid };
156+
}
157+
} catch {
158+
// port not listening or not a playground — ignore
159+
}
160+
return null;
161+
}
162+
163+
/**
164+
* Kill an existing playground process (best-effort, SIGTERM) and wait briefly for the port to free.
165+
* Returns true when the port appears released; false on timeout.
166+
*/
167+
async function replaceExistingPlayground(existing: ExistingPlayground, port: number): Promise<boolean> {
168+
log.info(`Replacing playground v${existing.version} (pid ${existing.pid}) on port ${port}...`);
169+
try {
170+
process.kill(existing.pid, "SIGTERM");
171+
} catch {
172+
// already gone — that's fine
173+
return true;
174+
}
175+
// Wait up to 3 s for the port to free (100 ms poll).
176+
for (let i = 0; i < 30; i++) {
177+
await new Promise((r) => setTimeout(r, 100));
178+
const still = await probeExistingPlayground(port);
179+
if (!still) return true;
180+
}
181+
return false;
182+
}
183+
136184
export async function playgroundCommand(options: PlaygroundOptions): Promise<void> {
137185
const port = options.port ? Number(options.port) : DEFAULT_PORT;
138186
if (!Number.isInteger(port) || port <= 0) {
@@ -143,14 +191,36 @@ export async function playgroundCommand(options: PlaygroundOptions): Promise<voi
143191
throw new Error(`Playground supports providers: ${supported}; received '${options.provider}'.`);
144192
}
145193

194+
// --- Detect and replace stale playground on the target port ----
195+
const version = cliVersion();
196+
const existing = await probeExistingPlayground(port);
197+
if (existing) {
198+
if (existing.version === version) {
199+
// Same version already running — just reuse it.
200+
const url = `http://localhost:${port}`;
201+
log.success(`Playground v${version} already running at ${url} (pid ${existing.pid})`);
202+
if (options.open !== false) openBrowser(url);
203+
return;
204+
}
205+
// Different version — replace the old instance so users always get the matching UI.
206+
const freed = await replaceExistingPlayground(existing, port);
207+
if (!freed) {
208+
log.warn(
209+
`Could not stop existing playground (pid ${existing.pid}) on port ${port}. ` +
210+
`Kill it manually and retry, or use --port to pick another port.`,
211+
);
212+
return;
213+
}
214+
}
215+
146216
const env: NodeJS.ProcessEnv = { ...process.env, PORT: String(port) };
147217
if (options.provider) {
148218
// AGENTS_CLI_PROVIDER 在 playground bootstrap(config.json force)之后写回,保证 CLI 显式指定优先生效。
149219
env.AGENTS_PROVIDER = options.provider;
150220
env.AGENTS_CLI_PROVIDER = options.provider;
151221
}
152222

153-
const { cmd, args } = resolveLauncher(cliVersion());
223+
const { cmd, args } = resolveLauncher(version);
154224
if (cmd === "npx") log.info(`Fetching ${PLAYGROUND_PKG} (first run may take a moment)...`);
155225

156226
const child = spawn(cmd, args, { env, stdio: ["inherit", "pipe", "inherit"] });

packages/playground/src/server.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,22 @@ export const DEFAULT_PLAYGROUND_PORT = 4848;
1616

1717
// Bundled webui build lives at <package>/web. This file ships to dist/bin/playground.js,
1818
// so the package root is two levels up from the emitted bundle.
19-
const webRoot = join(dirname(fileURLToPath(import.meta.url)), "../../web");
19+
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
20+
const webRoot = join(pkgRoot, "web");
2021
const indexHtml = injectPlaygroundRuntimeMarker(readFileSync(join(webRoot, "index.html"), "utf8"));
2122

23+
/** Read the playground package version once at startup (works for both source and published dist). */
24+
function readPlaygroundVersion(): string {
25+
try {
26+
const manifest = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8")) as { version?: string };
27+
return manifest.version ?? "unknown";
28+
} catch {
29+
return "unknown";
30+
}
31+
}
32+
33+
const playgroundVersion = readPlaygroundVersion();
34+
2235
function injectPlaygroundRuntimeMarker(html: string): string {
2336
if (html.includes('name="agents-runtime"')) return html;
2437
return html.replace("<head>", '<head>\n <meta name="agents-runtime" content="playground" />');
@@ -38,12 +51,23 @@ export async function startServer(): Promise<void> {
3851
}
3952

4053
const root = new Hono();
41-
// Server routes: /api/*, /health, /openapi.json (merged into the router).
54+
// Playground-enriched health check — registered before the server routes so it takes
55+
// precedence over the plain { status: "ok" } from apps/server. The `playground` field
56+
// lets the CLI detect a stale instance on the target port and replace it automatically.
57+
root.get("/health", (c) => c.json({ status: "ok", playground: { version: playgroundVersion, pid: process.pid } }));
58+
// Server routes: /api/*, /openapi.json (merged into the router).
4259
root.route("/", app);
4360
// Always serve the injected shell for document routes — static middleware would otherwise
4461
// return web/index.html without the playground runtime marker.
4562
root.get("/", (c) => c.html(indexHtml));
4663
root.get("/index.html", (c) => c.html(indexHtml));
64+
// Static assets — bust browser caches across playground upgrades. The filenames are
65+
// stable (`assets/index.js`, not hashed) because the console embed requires predictable
66+
// paths, so we instruct browsers to revalidate on every navigation.
67+
root.use("/assets/*", async (c, next) => {
68+
await next();
69+
c.header("Cache-Control", "no-cache");
70+
});
4771
root.use("/assets/*", serveStatic({ root: webRoot }));
4872
// SPA fallback: any unmatched GET renders the shell so client routing works on reload.
4973
root.get("*", (c) => c.html(indexHtml));

0 commit comments

Comments
 (0)