Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/remote-control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": minor
---

Add Remote Control, which makes the local web UI reachable from a phone or another computer. Run `pythinker rc`, or use `/rc` in the terminal UI, and scan the printed QR code. Enable it with `PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL=1`.
2 changes: 1 addition & 1 deletion apps/pythinker-code/dist-web/.web-bundle-manifest.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"sourceHash": "c05b9ce73324f5a446966d9f931dc804f0f1e73e78daf14e1479d86e58669c1a",
"sourceHash": "d84e17f04092f5fb9afa9f4d323b614d3945ec8e33abbbc92f5793ad2c30959e",
"sourceFileCount": 404
}
4 changes: 4 additions & 0 deletions apps/pythinker-code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,21 @@
"@pymodel/pythinker-telemetry": "workspace:^",
"@pymodel/vis-server": "workspace:^",
"@pymodel/vis-web": "workspace:*",
"@types/qrcode": "^1.5.6",
"@types/semver": "^7.7.0",
"@types/ws": "^8.18.0",
"@types/yazl": "^2.4.6",
"chalk": "^5.4.1",
"cli-highlight": "^2.1.11",
"commander": "^13.1.0",
"jimp": "^1.6.1",
"pathe": "^2.0.3",
"postject": "1.0.0-alpha.6",
"qrcode": "^1.5.4",
"semver": "^7.7.4",
"smol-toml": "^1.6.1",
"tsx": "^4.23.5",
"ws": "^8.21.3",
"yazl": "^3.3.1",
"zod": "^4.3.6"
},
Expand Down
10 changes: 10 additions & 0 deletions apps/pythinker-code/src/cli/sub/web/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { Command } from 'commander';
import { registerDeprecatedServerCommand } from './deprecated-server';
import { registerRotateTokenCommand } from './rotate-token';
import { buildWebCommand } from './run';
import { isRemoteControlEnabled } from './remote-control';

export function registerWebCommand(program: Command): void {
const web = buildWebCommand(
Expand All @@ -23,5 +24,14 @@ export function registerWebCommand(program: Command): void {
.description('Run the local Pythinker server and open the web UI.'),
);
registerRotateTokenCommand(web);
buildWebCommand(
program
.command('rc', { hidden: !isRemoteControlEnabled() })
.alias('remote')
.description(
'Run the local Pythinker server and open the web UI through Remote Control (experimental).',
),
{ forceRemoteControl: true },
);
registerDeprecatedServerCommand(program);
}
194 changes: 194 additions & 0 deletions apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { randomBytes } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { mkdir, open, readFile, unlink } from 'node:fs/promises';
import { dirname, join } from 'node:path';

export interface RemoteControlLockInfo {
readonly pid: number;
readonly nonce: string;
readonly localOrigin: string;
readonly deviceId: string;
readonly url: string;
readonly startedAt: number;
}

interface RemoteControlLockDisk {
readonly pid: number;
readonly nonce: string;
readonly local_origin: string;
readonly device_id: string;
readonly url: string;
readonly started_at: number;
}

export class RemoteControlAlreadyRunningError extends Error {
readonly holder: RemoteControlLockInfo;

constructor(holder: RemoteControlLockInfo) {
super(formatRemoteControlAlreadyRunning(holder));
this.name = 'RemoteControlAlreadyRunningError';
this.holder = holder;
}
}

export function formatRemoteControlAlreadyRunning(holder: RemoteControlLockInfo): string {
return [
`Remote Control is already running on this machine (pid ${holder.pid}, ${holder.localOrigin}, since ${new Date(holder.startedAt).toLocaleString()}).`,
`Use the existing link: ${holder.url}`,
'To start a new one here, stop the other `pythinker web --remote-control` process first.',
].join('\n');
}

export function remoteControlLockPath(homeDir: string): string {
return join(homeDir, 'server', 'rc.json');
}

export interface RemoteControlLock {
release(): Promise<void>;
}

const MAX_ACQUIRE_ATTEMPTS = 3;
const ACQUIRE_RETRY_DELAY_MS = 25;

export async function acquireRemoteControlLock(
homeDir: string,
details: { localOrigin: string; deviceId: string; url: string },
): Promise<RemoteControlLock> {
const lockPath = remoteControlLockPath(homeDir);
// The lock sits beside `server.token`; keep the same owner-only permissions.
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
const info: RemoteControlLockInfo = {
pid: process.pid,
nonce: randomBytes(8).toString('hex'),
localOrigin: details.localOrigin,
deviceId: details.deviceId,
url: details.url,
startedAt: Date.now(),
};
for (let attempt = 0; ; attempt += 1) {
try {
const handle = await open(lockPath, 'wx', 0o600);
try {
await handle.writeFile(encodeLock(info));
} finally {
await handle.close();
}
return { release: () => releaseRemoteControlLock(lockPath, info.nonce) };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw error;
}
// Read the holder even on the last attempt: a second process can recreate
// the lock between our unlink and our open, and a raw EEXIST tells the
// user nothing about who holds it or how to stop them.
const holder = await readRemoteControlLock(lockPath);
if (holder !== undefined && pidAlive(holder.pid)) {
throw new RemoteControlAlreadyRunningError(holder);
}
// `open(…, 'wx')` publishes an empty file before its JSON is written, so
// an unreadable lock may simply be a rival mid-write. Deleting it there
// would let both processes believe they hold the lock. Give the writer a
// moment and re-read; only sweep it once it is still unreadable at the
// end, which is the genuinely corrupt case.
if (holder === undefined && attempt < MAX_ACQUIRE_ATTEMPTS) {
await sleep(ACQUIRE_RETRY_DELAY_MS);
continue;
}
if (attempt >= MAX_ACQUIRE_ATTEMPTS) {
throw new Error(
`Unable to acquire the Remote Control lock at ${lockPath}. Another process keeps recreating it.`, { cause: error },
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
await removeFile(lockPath);
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export async function inspectRemoteControlLock(
homeDir: string,
): Promise<RemoteControlLockInfo | undefined> {
const lockPath = remoteControlLockPath(homeDir);
const info = await readRemoteControlLock(lockPath);
if (info === undefined) return undefined;
if (!pidAlive(info.pid)) {
await removeFile(lockPath);
return undefined;
}
return info;
}

async function releaseRemoteControlLock(lockPath: string, nonce: string): Promise<void> {
const info = await readRemoteControlLock(lockPath);
if (info === undefined || info.nonce !== nonce) return;
await removeFile(lockPath);
}

async function readRemoteControlLock(lockPath: string): Promise<RemoteControlLockInfo | undefined> {
let raw: string;
try {
raw = await readFile(lockPath, 'utf8');
} catch {
return undefined;
}
return decodeLock(raw);
}

function encodeLock(info: RemoteControlLockInfo): string {
const disk: RemoteControlLockDisk = {
pid: info.pid,
nonce: info.nonce,
local_origin: info.localOrigin,
device_id: info.deviceId,
url: info.url,
started_at: info.startedAt,
};
return JSON.stringify(disk);
}

function decodeLock(raw: string): RemoteControlLockInfo | undefined {
try {
const parsed = JSON.parse(raw) as Partial<RemoteControlLockDisk>;
if (
typeof parsed.pid === 'number' &&
// `process.kill(0, 0)` signals our own process group and reports "alive",
// so a corrupt `"pid": 0` would pin the lock forever.
Number.isInteger(parsed.pid) &&
parsed.pid > 0 &&
typeof parsed.nonce === 'string' &&
typeof parsed.local_origin === 'string' &&
typeof parsed.device_id === 'string' &&
typeof parsed.url === 'string' &&
typeof parsed.started_at === 'number'
) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return {
pid: parsed.pid,
nonce: parsed.nonce,
localOrigin: parsed.local_origin,
deviceId: parsed.device_id,
url: parsed.url,
startedAt: parsed.started_at,
};
}
return undefined;
} catch {
return undefined;
}
}

async function removeFile(lockPath: string): Promise<void> {
try {
await unlink(lockPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}

function pidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false;
return true;
}
}
Loading
Loading