mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-01 15:51:41 +02:00
v1.42.1.0 feat: gate terminal-agent teardown on ServerConfig.ownsTerminalAgent (unblocks gbrowser embedder) (#1615)
* feat: gate terminal-agent teardown on ServerConfig.ownsTerminalAgent Adds ownsTerminalAgent?: boolean to ServerConfig (default true). Wraps the three shutdown side effects (pkill -f terminal-agent\.ts + 2 safeUnlinkQuiet calls for terminal-port and terminal-internal-token) inside a single if (ownsTerminalAgent) block. Embedders (gbrowser phoenix overlay) pass false to keep their own PTY lifecycle intact across gstack's teardown. CLI start() call site passes ownsTerminalAgent: true explicitly; static-grep test in the new test file catches a refactor that drops it. Strict opt-out: only explicit false flips the gate (cfg.ownsTerminalAgent === false ? false : true). Defends against JS callers passing truthy non-bool values. Adds __resetShuttingDown test-only export mirroring __resetRegistry. The module-scoped isShuttingDown latch otherwise silently no-ops a second shutdown() in the same process. Drops dead try/catch wrappers around safeUnlinkQuiet inside the new gate — safeUnlinkQuiet already swallows all errors internally. New test file (4 cases) stubs both process.exit AND child_process.spawnSync so a real pkill -f terminal-agent\.ts never fires on the developer machine. beforeAll/afterAll save and restore real-daemon file contents in the state dir so the test cannot clobber a running gstack session. * chore: file followup TODOs (identity-based pkill, cfg.config composition gap, ownership-object trigger) Three P3 followups surfaced by /autoplan + /plan-eng-review while reviewing the ownsTerminalAgent gate: - Identity-based terminal-agent kill: pkill -f terminal-agent\.ts is a latent CLI footgun (regex match kills sibling gstack sessions, editor processes, etc.). Replace with PID-tracked process.kill at both cli.ts:1047 and server.ts:1281. - shutdown() reads module-level config, not cfg.config (pre-existing composition gap). Same gap applies to cleanSingletonLocks(resolveChromiumProfile()) at server.ts:1298 (should be cfg.chromiumProfile). Both are followup work for the embedder-composition story. - 4th caller-owned teardown gate trigger: today ServerConfig has 3 (xvfb?, proxyBridge?, ownsTerminalAgent). If a 4th appears, collapse to cfg.callerOwns?: Set<...> ownership object. * chore: bump version and changelog (v1.42.1.0) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: note ServerConfig.ownsTerminalAgent in CLAUDE.md sidebar block Adds a one-paragraph reference for the v1.42.1.0 embedder teardown gate right after the Sidebar architecture block. Covers default semantics, when embedders must pass `false`, polarity inversion vs xvfb?/proxyBridge?, and the static-grep CI test that pins the CLI call site. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+66
-9
@@ -205,6 +205,35 @@ export interface ServerConfig {
|
||||
* dispatch; returning null falls through.
|
||||
*/
|
||||
beforeRoute?: (req: Request, surface: Surface, auth: TokenInfo | null) => Promise<Response | null>;
|
||||
/**
|
||||
* Whether gstack owns the lifecycle of the terminal-agent process and its
|
||||
* discovery files (`<stateDir>/terminal-port`, `<stateDir>/terminal-internal-token`).
|
||||
*
|
||||
* When true (default), shutdown() runs three side effects:
|
||||
* 1. `pkill -f terminal-agent\.ts` — regex-broad, matches ANY process whose
|
||||
* command line contains `terminal-agent.ts` on this host (including
|
||||
* sibling gstack sessions). Pre-existing CLI behavior, not introduced by
|
||||
* this flag. Identity-based PID kill is a separate followup (see TODOS).
|
||||
* 2. `safeUnlinkQuiet(<stateDir>/terminal-port)`
|
||||
* 3. `safeUnlinkQuiet(<stateDir>/terminal-internal-token)`
|
||||
*
|
||||
* This is correct for gstack's CLI path, which spawns `terminal-agent.ts` as
|
||||
* the producer of those files (see cli.ts:1037-1063).
|
||||
*
|
||||
* Embedders (gbrowser phoenix overlay, future hosts) that run their own PTY
|
||||
* server and write those files themselves should pass `false`. When `false`,
|
||||
* the embedder owns BOTH the agent process AND both discovery files —
|
||||
* terminal-agent.ts's own SIGTERM cleanup only removes `terminal-port`
|
||||
* (see terminal-agent.ts:558), so the internal-token file is the embedder's
|
||||
* full responsibility.
|
||||
*
|
||||
* Polarity note: this differs from `xvfb?` and `proxyBridge?`, which gate by
|
||||
* the *presence* of a caller-owned handle (presence ⇒ don't close). This
|
||||
* field gates by an explicit boolean because there is no handle object —
|
||||
* the terminal-agent is started elsewhere (cli.ts), and shutdown's only
|
||||
* reference is the regex-based pkill + the file paths.
|
||||
*/
|
||||
ownsTerminalAgent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1229,8 +1258,11 @@ if (import.meta.main) {
|
||||
/**
|
||||
* Build a request handler set for the browse daemon. Embedders (gbrowser
|
||||
* phoenix overlay) call this directly with their own cfg to compose overlay
|
||||
* routes via cfg.beforeRoute. The CLI path calls it through start() with
|
||||
* env-derived defaults — externally-observable behavior is identical.
|
||||
* routes via cfg.beforeRoute, pass a pre-launched cfg.browserManager, and
|
||||
* opt out of terminal-agent teardown via cfg.ownsTerminalAgent (default
|
||||
* true, set to false when the embedder runs its own PTY server). The CLI
|
||||
* path calls this through start() with env-derived defaults and explicit
|
||||
* cfg.ownsTerminalAgent: true — externally-observable behavior is identical.
|
||||
*
|
||||
* Auth state lives ENTIRELY inside the factory closure: cfg.authToken is the
|
||||
* single source of truth for the bearer secret, factory-scoped validateAuth
|
||||
@@ -1260,6 +1292,11 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
initRegistry(cfg.authToken);
|
||||
|
||||
const { authToken, browserManager: cfgBrowserManager, startTime, beforeRoute, browsePort } = cfg;
|
||||
// Strict opt-out: only explicit `false` flips the gate. Any other value
|
||||
// (undefined, truthy non-bool from a JS caller bypassing TS, etc.) defaults
|
||||
// to gstack-owns. Matches the "default-true preserves CLI bit-for-bit"
|
||||
// premise even under malformed cfg.
|
||||
const ownsTerminalAgent = cfg.ownsTerminalAgent === false ? false : true;
|
||||
|
||||
// Factory-scoped validateAuth. Closes over cfg.authToken so every internal
|
||||
// auth check sees the same token the routes receive. Module-level
|
||||
@@ -1277,14 +1314,16 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||
isShuttingDown = true;
|
||||
|
||||
console.log('[browse] Shutting down...');
|
||||
try {
|
||||
const { spawnSync } = require('child_process');
|
||||
spawnSync('pkill', ['-f', 'terminal-agent\\.ts'], { stdio: 'ignore', timeout: 3000 });
|
||||
} catch (err: any) {
|
||||
console.warn('[browse] Failed to kill terminal-agent:', err.message);
|
||||
if (ownsTerminalAgent) {
|
||||
try {
|
||||
const { spawnSync } = require('child_process');
|
||||
spawnSync('pkill', ['-f', 'terminal-agent\\.ts'], { stdio: 'ignore', timeout: 3000 });
|
||||
} catch (err: any) {
|
||||
console.warn('[browse] Failed to kill terminal-agent:', err.message);
|
||||
}
|
||||
safeUnlinkQuiet(path.join(path.dirname(config.stateFile), 'terminal-port'));
|
||||
safeUnlinkQuiet(path.join(path.dirname(config.stateFile), 'terminal-internal-token'));
|
||||
}
|
||||
try { safeUnlinkQuiet(path.join(path.dirname(config.stateFile), 'terminal-port')); } catch {}
|
||||
try { safeUnlinkQuiet(path.join(path.dirname(config.stateFile), 'terminal-internal-token')); } catch {}
|
||||
try { detachSession(); } catch (err: any) {
|
||||
console.warn('[browse] Failed to detach CDP session:', err.message);
|
||||
}
|
||||
@@ -2541,6 +2580,7 @@ export async function start() {
|
||||
xvfb,
|
||||
proxyBridge,
|
||||
startTime,
|
||||
ownsTerminalAgent: true, // CLI spawns terminal-agent.ts itself (see cli.ts:1037-1063)
|
||||
});
|
||||
|
||||
const server = Bun.serve({
|
||||
@@ -2686,6 +2726,23 @@ export async function start() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only. Resets the module-level shutdown latch so a second test case
|
||||
* can exercise shutdown() in the same process. Mirrors __resetRegistry in
|
||||
* token-registry.ts. shutdown() short-circuits when isShuttingDown is true
|
||||
* (see line near the start of shutdown), so without this, tests that call
|
||||
* shutdown() more than once silently no-op after the first call.
|
||||
*
|
||||
* DO NOT call from production code. Defeats the shutdown re-entry guard,
|
||||
* which can race process.exit with cfgBrowserManager.close() and the pkill /
|
||||
* safeUnlinkQuiet side effects. The `__` prefix is the convention; nothing
|
||||
* enforces it. If you find yourself reaching for this outside a test file,
|
||||
* the right fix is to make isShuttingDown factory-scoped instead.
|
||||
*/
|
||||
export function __resetShuttingDown(): void {
|
||||
isShuttingDown = false;
|
||||
}
|
||||
|
||||
// Auto-kickoff only when this module is the entry point. Embedders
|
||||
// (gbrowser phoenix overlay) import { start, buildFetchHandler, ... }
|
||||
// without triggering the listener-binding side effects.
|
||||
|
||||
Reference in New Issue
Block a user