Skill Tracer
Skills › gstack › browse › chromium-profiles.ts

chromium-profiles.ts

212 lines · browse/src/chromium-profiles.ts at 3f79d81

1/**
2 * Per-project headed Chromium profiles (D5, #2492).
3 *
4 * The headed profile used to be machine-wide (<gstack home>/chromium-profile),
5 * so starting headed browse in one project SIGKILLed another project's live
6 * headed Chromium. Each project now gets <project>/.gstack/chromium-profile
7 * (config.resolveChromiumProfile). This module owns the profile lifecycle:
8 * who holds a profile's SingletonLock, first-use seeding from the old
9 * machine-wide profile, and `browse profiles list|prune`.
10 */
11import * as fs from 'fs';
12import * as os from 'os';
13import * as path from 'path';
14import { resolveGstackHome } from './config';
15import { isProcessAlive } from './error-handling';
16import { readPidCmdline } from './xvfb';
17import { atomicWriteSync } from '../../lib/fs-atomic';
19export type ProfileOwner =
20 | { state: 'free' }
21 | { state: 'orphan'; pid: number }
22 | { state: 'live'; pid: number; detail: string };
24/** Processes an orphan is reparented to: init, launchd, and common subreapers. */
25const REAPERS = new Set(['init', 'systemd', 'launchd', 'tini', 'dumb-init', 'docker-init']);
27export function legacyProfileDir(): string {
28 return path.join(resolveGstackHome(), 'chromium-profile');
31function registryPath(): string {
32 return path.join(resolveGstackHome(), 'browse-profiles.json');
35function readParentPid(pid: number): number {
36 try {
37 const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf-8');
38 return Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1]);
39 } catch {
40 try {
41 const r = Bun.spawnSync(['ps', '-o', 'ppid=', '-p', String(pid)], { stdout: 'pipe', stderr: 'pipe', timeout: 2000, windowsHide: true });
42 return r.exitCode === 0 ? Number(r.stdout.toString().trim()) : 0;
43 } catch {
44 return 0;
45 }
46 }
49/**
50 * Who holds this profile. The SingletonLock symlink target is "host-PID".
51 * Only a process on this host that is a Chromium using this exact profile
52 * counts as the holder; a dead or recycled PID means the lock is stale. An
53 * orphan (reparented to init or a subreaper after its daemon died) may be
54 * reaped; anything else, including a lock from another host, is a live owner
55 * that must never be killed.
56 */
57export function profileOwner(profileDir: string): ProfileOwner {
58 let target: string;
59 try {
60 target = fs.readlinkSync(path.join(profileDir, 'SingletonLock'));
61 } catch {
62 return { state: 'free' };
63 }
64 const dash = target.lastIndexOf('-');
65 const host = target.slice(0, dash);
66 const pid = Number(target.slice(dash + 1));
67 if (!Number.isSafeInteger(pid) || pid <= 0) return { state: 'free' };
68 if (host !== os.hostname()) {
69 return { state: 'live', pid, detail: `locked by PID ${pid} on another host (${host})` };
70 }
71 if (!isProcessAlive(pid)) return { state: 'free' };
72 const cmd = readPidCmdline(pid);
73 const dirs = new Set([profileDir]);
74 try { dirs.add(fs.realpathSync(profileDir)); } catch {}
75 const usesProfile = [...dirs].some(d => cmd.includes(`--user-data-dir=${d}`));
76 if (!/chrom/i.test(cmd) || !usesProfile) return { state: 'free' };
77 const ppid = readParentPid(pid);
78 const parent = ppid > 1 ? path.basename(readPidCmdline(ppid).split(' ')[0] || '') : '';
79 if (ppid <= 1 || !isProcessAlive(ppid) || REAPERS.has(parent)) return { state: 'orphan', pid };
80 return { state: 'live', pid, detail: `in use by Chromium PID ${pid} (started by PID ${ppid}: ${parent || 'unknown'})` };
83function readRegistry(): string[] {
84 try {
85 const list = JSON.parse(fs.readFileSync(registryPath(), 'utf-8'));
86 return Array.isArray(list) ? list.filter((p): p is string => typeof p === 'string') : [];
87 } catch {
88 return [];
89 }
92function writeRegistry(list: string[]): void {
93 fs.mkdirSync(path.dirname(registryPath()), { recursive: true });
94 atomicWriteSync(registryPath(), JSON.stringify([...new Set(list)].sort(), null, 2) + '\n');
97/**
98 * Create a project's headed profile on first use. Seeds it from the old
99 * machine-wide profile only when nothing holds that profile, copying into a
100 * temporary 0700 directory without Singleton* files and renaming it into
101 * place, so a concurrent first use or an interrupted copy never leaves a
102 * half-written profile. Says what happened once, through `log`.
103 */
104export function ensureProjectProfile(profileDir: string, log: (msg: string) => void = console.log): void {
105 if (fs.existsSync(profileDir)) return;
106 // An explicit CHROMIUM_PROFILE belongs to its caller (gbrowser's per-workspace
107 // gbd): never seed it from the shared profile or register it for pruning.
108 const explicit = process.env.CHROMIUM_PROFILE;
109 if (explicit && path.resolve(explicit) === path.resolve(profileDir)) return;
110 const legacy = legacyProfileDir();
111 if (path.resolve(profileDir) === path.resolve(legacy)) return;
112 writeRegistry([...readRegistry(), path.resolve(profileDir)]);
113 fs.mkdirSync(path.dirname(profileDir), { recursive: true });
114 const fresh = (why: string) => {
115 fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
116 if (why) log(why);
117 };
118 if (!fs.existsSync(legacy)) return fresh('');
119 const before = profileOwner(legacy);
120 if (before.state !== 'free') {
121 return fresh(`[browse] Headed browse now keeps a profile per project (${profileDir}). The old shared profile (${legacy}) is ${'detail' in before ? before.detail : `held by orphaned PID ${before.pid}`}, so this project starts with a fresh profile. To import it later: close that browser, delete ${profileDir}, and start headed browse again.`);
122 }
123 const tmp = `${profileDir}.seed-${process.pid}`;
124 fs.rmSync(tmp, { recursive: true, force: true });
125 try {
126 fs.cpSync(legacy, tmp, {
127 recursive: true,
128 verbatimSymlinks: true,
129 filter: (src) => !path.basename(src).startsWith('Singleton'),
130 });
131 fs.chmodSync(tmp, 0o700);
132 if (profileOwner(legacy).state !== 'free') {
133 fs.rmSync(tmp, { recursive: true, force: true });
134 return fresh(`[browse] The old shared profile (${legacy}) came into use while it was being copied, so this project (${profileDir}) starts with a fresh profile.`);
135 }
136 fs.renameSync(tmp, profileDir);
137 } catch (err) {
138 fs.rmSync(tmp, { recursive: true, force: true });
139 if (fs.existsSync(profileDir)) return;
140 throw err;
141 }
142 log(`[browse] Headed browse now keeps a profile per project. Copied your logins and settings from ${legacy} to ${profileDir} (${formatBytes(dirBytes(profileDir))}). See \`browse profiles\` to list or prune profiles.`);
145function dirBytes(dir: string): number {
146 let total = 0;
147 const walk = (d: string) => {
148 for (const e of fs.readdirSync(d, { withFileTypes: true })) {
149 const p = path.join(d, e.name);
150 if (e.isDirectory()) walk(p);
151 else if (e.isFile()) total += fs.statSync(p).size;
152 }
153 };
154 try { walk(dir); } catch {}
155 return total;
158function formatBytes(n: number): string {
159 if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
160 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
163function describeOwner(owner: ProfileOwner): string {
164 if (owner.state === 'free') return 'idle';
165 if (owner.state === 'orphan') return `held by orphaned PID ${owner.pid}`;
166 return owner.detail;
169/** `browse profiles [list]` and `browse profiles prune [--days N]`. Returns the exit code. */
170export function runProfilesCommand(args: string[], out: (line: string) => void = console.log): number {
171 const sub = args[0] ?? 'list';
172 const known = readRegistry();
173 const legacy = legacyProfileDir();
174 if (sub === 'list') {
175 const rows = [...(fs.existsSync(legacy) ? [legacy] : []), ...known];
176 if (rows.length === 0) {
177 out('No headed browser profiles yet. One is created per project on first `browse --headed` or `browse connect`.');
178 return 0;
179 }
180 for (const dir of rows) {
181 if (!fs.existsSync(dir)) { out(`${dir} (missing: project moved or deleted; \`browse profiles prune\` forgets it)`); continue; }
182 const age = Math.floor((Date.now() - fs.statSync(dir).mtimeMs) / 86_400_000);
183 out(`${dir} ${formatBytes(dirBytes(dir))} last used ${age}d ago ${describeOwner(profileOwner(dir))}${dir === legacy ? ' (old shared profile)' : ''}`);
184 }
185 return 0;
186 }
187 if (sub === 'prune') {
188 const daysIdx = args.indexOf('--days');
189 const days = daysIdx >= 0 ? Number(args[daysIdx + 1]) : 30;
190 if (!Number.isFinite(days) || days < 0) {
191 out('Usage: browse profiles prune [--days N] (default 30)');
192 return 2;
193 }
194 const keep: string[] = [];
195 let freed = 0;
196 for (const dir of known) {
197 if (!fs.existsSync(dir)) { out(`forgot ${dir} (no longer exists)`); continue; }
198 const idleDays = (Date.now() - fs.statSync(dir).mtimeMs) / 86_400_000;
199 const owner = profileOwner(dir);
200 if (idleDays < days || owner.state !== 'free') { keep.push(dir); continue; }
201 const bytes = dirBytes(dir);
202 fs.rmSync(dir, { recursive: true, force: true });
203 freed += bytes;
204 out(`removed ${dir} (${formatBytes(bytes)}, idle ${Math.floor(idleDays)}d)`);
205 }
206 writeRegistry(keep);
207 out(`Pruned profiles idle for ${days}+ days; freed ${formatBytes(freed)}. Profiles in use are never removed. The old shared profile (${legacy}) is left alone.`);
208 return 0;
209 }
210 out('Usage: browse profiles [list] | browse profiles prune [--days N]');
211 return 2;