Skip to content
Closed
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: 2 additions & 0 deletions packages/local-runtime-v2/src/service/miniapp/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type MiniAppFailureReasonCode =
| 'HOST_CONNECTOR_PROTOCOL_VIOLATION'
| 'HOST_CONNECTOR_UNAVAILABLE'
| 'NO_CANDIDATE_PORT'
| 'OWNED_ELSEWHERE'
| 'READINESS_PROBE_REJECTED'
| 'READINESS_PROBE_TIMEOUT'
| 'READINESS_TIMEOUT'
Expand All @@ -36,6 +37,7 @@ const MINIAPP_RUNTIME_FAILURE_REASON_CODES: ReadonlySet<string> = new Set<MiniAp
'HOST_CONNECTOR_PROTOCOL_VIOLATION',
'HOST_CONNECTOR_UNAVAILABLE',
'NO_CANDIDATE_PORT',
'OWNED_ELSEWHERE',
'READINESS_PROBE_REJECTED',
'READINESS_PROBE_TIMEOUT',
'READINESS_TIMEOUT',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { request as httpRequest } from 'node:http';

import { MINIAPP_LOOPBACK_HEALTH_PATH } from '../../contracts.js';

/**
* What is holding a derived Mini App port.
*
* - `free` — nothing is listening; the port is this client's to take.
* - `guarded` — a Mini App's own guard answered: another client is running this Mini App, or a
* previous generation of this one outlived its owner. Only the process that minted the credential
* can talk to it, so this client refuses rather than starting a second process over the same
* plugin data.
* - `foreign` — something else holds the port and does not answer like the guard. Not this Mini App;
* the runtime may pick another port.
*/
export type MiniAppPortOwnership = 'free' | 'guarded' | 'foreign';

export interface MiniAppPortProbeInput {
readonly port: number;
readonly timeoutMs: number;
readonly signal: AbortSignal;
}

/**
* Probes a derived port before a Mini App is spawned. The shipped desktop client writes no ownership
* record, so for anything already running this is the only signal there is, and it has to be the
* guard's own answer rather than a bare connect: a socket that accepts proves nothing.
*/
export async function probeMiniAppPortOwnership(
input: MiniAppPortProbeInput,
): Promise<MiniAppPortOwnership> {
let status: number;
try {
status = await requestHealth(input);
} catch {
return 'free';
}
return status === 401 ? 'guarded' : 'foreign';
}

function requestHealth(input: MiniAppPortProbeInput): Promise<number> {
if (input.signal.aborted) return Promise.reject(input.signal.reason);
return new Promise((resolve, reject) => {
let settled = false;
const finish = (outcome: { readonly status: number } | { readonly error: unknown }) => {
if (settled) return;
settled = true;
clearTimeout(timer);
input.signal.removeEventListener('abort', onAbort);
if ('status' in outcome) {
resolve(outcome.status);
return;
}
request.destroy();
reject(outcome.error);
};
const request = httpRequest(
{
host: '127.0.0.1',
port: input.port,
method: 'GET',
path: MINIAPP_LOOPBACK_HEALTH_PATH,
agent: false,
},
(response) => {
response.on('error', () => undefined);
response.resume();
finish({ status: response.statusCode ?? 0 });
},
);
const onAbort = () => finish({ error: input.signal.reason });
const timer = setTimeout(
() => finish({ error: new Error('Mini App port probe timed out') }),
Math.max(1, input.timeoutMs),
);
timer.unref();
request.on('error', (error) => finish({ error }));
input.signal.addEventListener('abort', onAbort, { once: true });
request.end();
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { MiniAppCleanupUnprovenError, markMiniAppRootTerminationProven } from '.
import { sanitizeMiniAppRuntimeLogEvent } from '../../runtime-log.js';
import type { MiniAppRunnerInitMessage } from './host-runner.js';
import { waitForLoopbackReadiness } from './loopback-readiness.js';
import { acquireMiniAppOwner, type MiniAppOwnerIdentity } from './owner-lease.js';
import {
createDirectNodeRootTerminator,
type DirectNodeRootTerminator,
Expand Down Expand Up @@ -86,6 +87,8 @@ interface NodeRuntimeOptions {
readonly resolveRealPath?: (target: string) => Promise<string>;
readonly makeStartupToken?: () => string;
readonly makeLoopbackToken?: () => string;
/** Label this client records when it takes ownership of a Mini App. */
readonly ownerClient?: string;
readonly nowMs?: () => number;
readonly wait?: (ms: number, signal: AbortSignal) => Promise<void>;
readonly runnerStartTimeoutMs?: number;
Expand Down Expand Up @@ -115,6 +118,7 @@ type ResolvedOptions = Required<
| 'resolveRealPath'
| 'makeStartupToken'
| 'makeLoopbackToken'
| 'ownerClient'
| 'nowMs'
| 'wait'
| 'runnerStartTimeoutMs'
Expand Down Expand Up @@ -227,6 +231,38 @@ async function prepareRuntime(input: {
readonly dataDir: string;
readonly runtimeDir: string;
}): Promise<MiniAppNodeRuntime> {
// Ownership is taken before the port loop and before any spawn: a second client must never reach
// the point of starting a process over the same plugin data.
const ownership = await acquireMiniAppOwner({
pluginDataDir: input.dataDir,
pluginId: input.candidate.pluginId,
identity: { client: input.options.ownerClient },
nowMs: input.options.nowMs,
});
if (ownership.kind === 'held') {
throw new MiniAppNodeRuntimeError(
'OWNED_ELSEWHERE',
ownerHeldDetail(ownership.owner, input.candidate.pluginId),
);
}
try {
return await preparedRuntimeWithOwnership(input, ownership);
} catch (error) {
await ownership.release();
throw error;
}
}

function ownerHeldDetail(owner: MiniAppOwnerIdentity | undefined, pluginId: string): string {
return owner
? `Mini App ${pluginId} is already running in ${owner.client} (pid ${owner.pid})`
: `Mini App ${pluginId} is already running in another client`;
}

async function preparedRuntimeWithOwnership(
input: Parameters<typeof prepareRuntime>[0],
ownership: { release(): Promise<void> },
): Promise<MiniAppNodeRuntime> {
// One credential per process generation: every port attempt reuses it.
const loopbackToken = input.options.makeLoopbackToken();
let lastError: unknown;
Expand Down Expand Up @@ -265,6 +301,7 @@ async function prepareRuntime(input: {
port,
reservation,
loopbackToken,
ownership,
});
} catch (error) {
const failure = await selectFailureAfterCleanup(error, () =>
Expand Down Expand Up @@ -626,6 +663,7 @@ function preparedRuntime(
readonly port: number;
readonly reservation?: { release(): void };
readonly loopbackToken: string;
readonly ownership: { release(): Promise<void> };
},
): MiniAppNodeRuntime {
let stopPromise: Promise<{ readonly proven: boolean }> | undefined;
Expand Down Expand Up @@ -659,6 +697,11 @@ function preparedRuntime(
} catch (removeError) {
postStopErrors.push(removeError);
}
try {
await input.ownership.release();
} catch (releaseError) {
postStopErrors.push(releaseError);
}
throwPostStopErrors(postStopErrors);
return { proven: true };
})();
Expand Down Expand Up @@ -769,6 +812,7 @@ function resolveOptions(options: NodeRuntimeOptions): ResolvedOptions {
resolveRealPath: realpath,
makeStartupToken: randomUUID,
makeLoopbackToken: () => randomBytes(32).toString('base64url'),
ownerClient: 'unknown',
nowMs: Date.now,
wait: defaultWait,
runnerStartTimeoutMs: 10_000,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import net from 'node:net';
import path from 'node:path';

/**
* Who holds a Mini App, recorded beside the endpoint that proves it.
*
* The binding is the truth and this record is the explanation: a live owner holds its endpoint, and the
* operating system releases that binding when the owner dies. So a record whose endpoint nobody holds is
* stale by definition, whatever its timestamp says.
*/
export interface MiniAppOwnerIdentity {
readonly client: string;
readonly pid: number;
readonly startedAtMs: number;
}

export type MiniAppOwnerAcquisition =
| { readonly kind: 'acquired'; release(): Promise<void> }
| { readonly kind: 'held'; readonly owner?: MiniAppOwnerIdentity };

export interface AcquireMiniAppOwnerInput {
/** The Mini App's own directory in the shared data directory. */
readonly pluginDataDir: string;
readonly pluginId: string;
readonly identity: Omit<MiniAppOwnerIdentity, 'pid' | 'startedAtMs'>;
readonly nowMs?: () => number;
readonly platform?: NodeJS.Platform;
}

const SOCKET_FILE = 'owner.sock';
const RECORD_FILE = 'owner.json';
const WINDOWS_PIPE_PREFIX = '\\\\.\\pipe\\mcode-miniapp-owner-';

/** A socket file on POSIX, a named pipe on Windows — named from the plugin id, never from the directory. */
export function resolveMiniAppOwnerEndpoint(
pluginDataDir: string,
pluginId: string,
platform: NodeJS.Platform = process.platform,
): string {
if (platform === 'win32') {
const digest = createHash('sha256').update(pluginId).digest('hex').slice(0, 16);
return `${WINDOWS_PIPE_PREFIX}${digest}`;
}
return path.join(pluginDataDir, SOCKET_FILE);
}

/**
* Takes ownership of one Mini App, or reports who holds it.
*
* A failed bind means somebody is listening; an unheld socket file means a previous owner died without
* cleaning up. The two are told apart by trying to connect: a refused connection proves the file is a
* leftover, and only then is it removed. Nothing is unlinked before the first bind attempt, because that
* would steal a live owner's endpoint.
*/
export async function acquireMiniAppOwner(
input: AcquireMiniAppOwnerInput,
): Promise<MiniAppOwnerAcquisition> {
const nowMs = input.nowMs ?? Date.now;
const platform = input.platform ?? process.platform;
const endpoint = resolveMiniAppOwnerEndpoint(input.pluginDataDir, input.pluginId, platform);
if (platform !== 'win32') await mkdir(input.pluginDataDir, { recursive: true });

for (let attempt = 0; attempt < 2; attempt += 1) {
const server = net.createServer((socket) => socket.destroy());
const bound = await bind(server, endpoint);
if (bound) {
const identity: MiniAppOwnerIdentity = {
client: input.identity.client,
pid: process.pid,
startedAtMs: nowMs(),
};
await writeOwnerRecord(input.pluginDataDir, identity);
let released = false;
return {
kind: 'acquired',
async release() {
if (released) return;
released = true;
await close(server);
await rm(endpoint, { force: true });
await rm(path.join(input.pluginDataDir, RECORD_FILE), { force: true });
},
};
}
if (attempt === 0 && (await isLeftoverEndpoint(endpoint))) {
await rm(endpoint, { force: true });
continue;
}
return { kind: 'held', ...(await readOwnerRecord(input.pluginDataDir)) };
}
return { kind: 'held', ...(await readOwnerRecord(input.pluginDataDir)) };
}

function bind(server: net.Server, endpoint: string): Promise<boolean> {
return new Promise((resolve, reject) => {
const onError = (error: NodeJS.ErrnoException) => {
server.removeListener('listening', onListening);
server.close();
if (error.code === 'EADDRINUSE') {
resolve(false);
return;
}
reject(error);
};
const onListening = () => {
server.removeListener('error', onError);
resolve(true);
};
server.once('error', onError);
server.once('listening', onListening);
server.listen(endpoint);
});
}

/** True only when the endpoint exists and nothing answers on it. */
function isLeftoverEndpoint(endpoint: string): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.connect(endpoint);
const finish = (leftover: boolean) => {
socket.removeAllListeners();
socket.destroy();
resolve(leftover);
};
socket.once('connect', () => finish(false));
socket.once('error', (error: NodeJS.ErrnoException) => {
finish(error.code === 'ECONNREFUSED' || error.code === 'ENOENT');
});
});
}

function close(server: net.Server): Promise<void> {
return new Promise((resolve) => server.close(() => resolve()));
}

async function writeOwnerRecord(
pluginDataDir: string,
identity: MiniAppOwnerIdentity,
): Promise<void> {
try {
await writeFile(
path.join(pluginDataDir, RECORD_FILE),
`${JSON.stringify(identity)}\n`,
'utf8',
);
} catch {
// The binding already proves ownership; a record that cannot be written changes nothing.
}
}

async function readOwnerRecord(
pluginDataDir: string,
): Promise<{ readonly owner?: MiniAppOwnerIdentity }> {
try {
const raw = JSON.parse(await readFile(path.join(pluginDataDir, RECORD_FILE), 'utf8')) as unknown;
if (
raw &&
typeof raw === 'object' &&
typeof (raw as MiniAppOwnerIdentity).client === 'string' &&
typeof (raw as MiniAppOwnerIdentity).pid === 'number'
) {
return { owner: raw as MiniAppOwnerIdentity };
}
} catch {
// An absent or unreadable record still leaves the endpoint answering for the owner.
}
return {};
}
Loading
Loading