Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ jobs:
test-web-assets:
name: Build test web assets
needs: change-check
if: needs.change-check.outputs.boxel == 'true' || needs.change-check.outputs.boxel-ui == 'true' || needs.change-check.outputs.matrix == 'true' || needs.change-check.outputs.realm-server == 'true' || needs.change-check.outputs.vscode-boxel-tools == 'true' || needs.change-check.outputs.workspace-sync-cli == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true'
if: needs.change-check.outputs.boxel == 'true' || needs.change-check.outputs.boxel-ui == 'true' || needs.change-check.outputs.matrix == 'true' || needs.change-check.outputs.realm-server == 'true' || needs.change-check.outputs.vscode-boxel-tools == 'true' || needs.change-check.outputs.workspace-sync-cli == 'true' || needs.change-check.outputs.boxel-cli == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true'
uses: ./.github/workflows/test-web-assets.yaml
with:
caller: ci
Expand Down
12 changes: 12 additions & 0 deletions packages/boxel-cli/src/commands/realm/watch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Command } from 'commander';
import { registerStartCommand } from './start';
import { registerStopCommand } from './stop';

export function registerWatchCommand(realm: Command): void {
const watch = realm
.command('watch')
.description('Watch a Boxel realm; subcommands manage watch processes');

registerStartCommand(watch);
registerStopCommand(watch);
}
Original file line number Diff line number Diff line change
@@ -1,35 +1,39 @@
import { InvalidArgumentError, type Command } from 'commander';
import * as fs from 'fs/promises';
import * as path from 'path';
import { RealmSyncBase, isProtectedFile } from '../../lib/realm-sync-base';
import { RealmSyncBase, isProtectedFile } from '../../../lib/realm-sync-base';
import {
CheckpointManager,
type Checkpoint,
type CheckpointChange,
} from '../../lib/checkpoint-manager';
} from '../../../lib/checkpoint-manager';
import {
type SyncManifest,
computeFileHash,
loadManifest,
saveManifest,
} from '../../lib/sync-manifest';
import type { ProfileManager } from '../../lib/profile-manager';
import type { RealmAuthenticator } from '../../lib/realm-authenticator';
import { resolveRealmAuthenticator } from '../../lib/auth-resolver';
import { resolveRealmSecretSeed } from '../../lib/prompt';
} from '../../../lib/sync-manifest';
import type { ProfileManager } from '../../../lib/profile-manager';
import type { RealmAuthenticator } from '../../../lib/realm-authenticator';
import { resolveRealmAuthenticator } from '../../../lib/auth-resolver';
import { resolveRealmSecretSeed } from '../../../lib/prompt';
import {
acquireWatchLock,
releaseWatchLock,
type WatchLockInfo,
} from '../../lib/watch-lock';
} from '../../../lib/watch-lock';
import {
registerProcess,
unregisterCurrentProcess,
} from '../../../lib/watch-process-registry';
import {
FG_CYAN,
FG_GREEN,
FG_RED,
FG_YELLOW,
DIM,
RESET,
} from '../../lib/colors';
} from '../../../lib/colors';

export interface WatchRealmSpec {
realmUrl: string;
Expand Down Expand Up @@ -472,6 +476,12 @@ export async function watchRealms(
}, intervalMs);
};

try {
await registerProcess(specs.map((s) => s.localDir).join(', '));
} catch {
// Best effort — registry failures must never block the watch.
}

await tickAll();
scheduleNextTick();

Expand All @@ -496,6 +506,11 @@ export async function watchRealms(
// Best effort \u2014 a leftover lock will be detected as stale next run.
}
}
try {
await unregisterCurrentProcess();
} catch {
// Best effort \u2014 leftover entries are pruned on next read.
}
resolve();
};

Expand Down Expand Up @@ -572,11 +587,11 @@ function parseNonNegativeSeconds(name: string): (value: string) => number {
};
}

export function registerWatchCommand(realm: Command): void {
realm
.command('watch')
export function registerStartCommand(watch: Command): void {
watch
.command('start')
.description(
'Watch a Boxel realm for server-side changes and pull them into a local directory',
'Start watching a Boxel realm for server-side changes and pull them into a local directory',
)
.argument(
'<realm-url>',
Expand Down
151 changes: 151 additions & 0 deletions packages/boxel-cli/src/commands/realm/watch/stop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { execSync } from 'child_process';
import type { Command } from 'commander';
import { listRegisteredProcesses } from '../../../lib/watch-process-registry';
import { DIM, FG_GREEN, FG_RED, RESET } from '../../../lib/colors';

export interface StoppedProcess {
pid: number;
workspace: string;
}

export interface StopResult {
stopped: StoppedProcess[];
failed: StoppedProcess[];
}

const SETTLE_MS = 200;

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function signalProcess(pid: number): { ok: boolean; alreadyGone: boolean } {
try {
if (process.platform === 'win32') {
try {
process.kill(pid);
} catch {
execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore' });
}
} else {
process.kill(pid, 'SIGINT');
}
return { ok: true, alreadyGone: false };
} catch (err: any) {
if (err?.code === 'ESRCH') {
return { ok: true, alreadyGone: true };
}
return { ok: false, alreadyGone: false };
}
}

interface PsHit {
pid: number;
workspace: string;
}

function findViaProcessTable(): PsHit[] {
if (process.platform === 'win32') return [];
let output: string;
try {
output = execSync(
'ps aux | grep -E "(tsx[[:space:]].*src/index\\.ts[[:space:]]+realm[[:space:]]+watch[[:space:]]+start|[[:space:]]boxel[[:space:]]+realm[[:space:]]+watch[[:space:]]+start|node[[:space:]].*boxel[[:space:]]+realm[[:space:]]+watch[[:space:]]+start)" | grep -v grep | grep -v "[[:space:]]stop"',
{ encoding: 'utf8' },
).trim();
} catch {
return [];
}
if (!output) return [];

const hits: PsHit[] = [];
const seen = new Set<number>();
for (const line of output.split('\n')) {
if (!line) continue;
const parts = line.trim().split(/\s+/);
const pid = Number.parseInt(parts[1] ?? '', 10);
if (!Number.isFinite(pid) || seen.has(pid)) continue;
seen.add(pid);

let workspace = '.';
const match = line.match(/\bstart\s+\S+\s+(\S+)/);
if (match && match[1] && !match[1].startsWith('-')) {
workspace = match[1];
}
hits.push({ pid, workspace });
}
return hits;
}

export async function stopWatchProcesses(): Promise<StopResult> {
const stopped: StoppedProcess[] = [];
const failed: StoppedProcess[] = [];
const targetedPids = new Set<number>();

const registered = await listRegisteredProcesses();
for (const proc of registered) {
if (proc.pid === process.pid) continue;
targetedPids.add(proc.pid);
const result = signalProcess(proc.pid);
const record: StoppedProcess = { pid: proc.pid, workspace: proc.workspace };
if (result.ok) {
stopped.push(record);
} else {
failed.push(record);
}
}

for (const hit of findViaProcessTable()) {
if (hit.pid === process.pid) continue;
if (targetedPids.has(hit.pid)) continue;
targetedPids.add(hit.pid);
const result = signalProcess(hit.pid);
const record: StoppedProcess = { pid: hit.pid, workspace: hit.workspace };
if (result.ok) {
stopped.push(record);
} else {
failed.push(record);
}
}

if (stopped.length > 0) {
await sleep(SETTLE_MS);
// Trigger another prune so the registry doesn't keep stale entries
// for processes that exited cleanly above.
await listRegisteredProcesses();
}

return { stopped, failed };
}

function printResult(result: StopResult): void {
if (result.stopped.length === 0 && result.failed.length === 0) {
console.log('No running watch processes found.');
return;
}
for (const proc of result.stopped) {
console.log(
` ${DIM}⇅${RESET} Stopped: boxel realm watch ${proc.workspace} (PID ${proc.pid})`,
);
}
for (const proc of result.failed) {
console.log(
` ${FG_RED}×${RESET} Failed to stop: boxel realm watch ${proc.workspace} (PID ${proc.pid})`,
);
}
if (result.stopped.length > 0) {
const plural = result.stopped.length > 1 ? 'es' : '';
console.log(
`\n${FG_GREEN}✓ Stopped ${result.stopped.length} process${plural}${RESET}`,
);
}
}

export function registerStopCommand(watch: Command): void {
watch
.command('stop')
.description('Stop all running boxel realm watch processes')
.action(async () => {
const result = await stopWatchProcesses();
printResult(result);
});
}
2 changes: 1 addition & 1 deletion packages/boxel-cli/src/lib/watch-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ function lockPath(localDir: string): string {
return path.join(localDir, LOCK_FILE);
}

function isProcessAlive(pid: number): boolean {
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
Expand Down
85 changes: 85 additions & 0 deletions packages/boxel-cli/src/lib/watch-process-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { isProcessAlive } from './watch-lock';

export interface RegisteredProcess {
pid: number;
workspace: string;
startedAt: string;
}

interface Registry {
processes: RegisteredProcess[];
}

function registryDir(): string {
return path.join(os.homedir(), '.boxel-cli');
}

function registryFile(): string {
return path.join(registryDir(), 'watch-processes.json');
}

async function readRegistry(): Promise<Registry> {
try {
const raw = await fs.readFile(registryFile(), 'utf8');
const parsed = JSON.parse(raw) as Partial<Registry>;
if (!Array.isArray(parsed?.processes)) {
return { processes: [] };
}
const processes = parsed.processes.filter(
(entry): entry is RegisteredProcess =>
typeof entry?.pid === 'number' &&
typeof entry?.workspace === 'string' &&
typeof entry?.startedAt === 'string',
);
return { processes };
} catch {
return { processes: [] };
}
}

async function writeRegistry(registry: Registry): Promise<void> {
await fs.mkdir(registryDir(), { recursive: true });
const target = registryFile();
const tmp = `${target}.${process.pid}.tmp`;
await fs.writeFile(tmp, JSON.stringify(registry, null, 2) + '\n');
await fs.rename(tmp, target);
}

async function pruneDead(): Promise<Registry> {
const registry = await readRegistry();
const alive = registry.processes.filter((entry) => isProcessAlive(entry.pid));
if (alive.length !== registry.processes.length) {
await writeRegistry({ processes: alive });
}
return { processes: alive };
}

export async function registerProcess(workspace: string): Promise<void> {
const registry = await pruneDead();
const withoutCurrent = registry.processes.filter(
(entry) => entry.pid !== process.pid,
);
withoutCurrent.push({
pid: process.pid,
workspace,
startedAt: new Date().toISOString(),
});
await writeRegistry({ processes: withoutCurrent });
}

export async function unregisterCurrentProcess(): Promise<void> {
const registry = await readRegistry();
const next = registry.processes.filter((entry) => entry.pid !== process.pid);
if (next.length === registry.processes.length) {
return;
}
await writeRegistry({ processes: next });
}

export async function listRegisteredProcesses(): Promise<RegisteredProcess[]> {
const registry = await pruneDead();
return registry.processes;
}
Loading
Loading