Skip to content

Commit 391fefa

Browse files
fix(local-runtime-v2): enforce Mini App cross-client ownership
1 parent 193ee9c commit 391fefa

4 files changed

Lines changed: 194 additions & 13 deletions

File tree

‎packages/local-runtime-v2/src/service/miniapp/process/node-runtime/loopback-ownership.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import { MINIAPP_LOOPBACK_HEALTH_PATH } from '../../contracts.js';
1212
* plugin data.
1313
* - `foreign` — something else holds the port and does not answer like the guard. Not this Mini App;
1414
* the runtime may pick another port.
15+
* - `unknown` — the probe could not distinguish a listener from a free port; callers must fail
16+
* closed rather than start on a different port.
1517
*/
16-
export type MiniAppPortOwnership = 'free' | 'guarded' | 'foreign';
18+
export type MiniAppPortOwnership = 'free' | 'guarded' | 'foreign' | 'unknown';
1719

1820
export interface MiniAppPortProbeInput {
1921
readonly port: number;
@@ -32,12 +34,23 @@ export async function probeMiniAppPortOwnership(
3234
let status: number;
3335
try {
3436
status = await requestHealth(input);
35-
} catch {
36-
return 'free';
37+
} catch (error) {
38+
if (input.signal.aborted) throw input.signal.reason;
39+
if (isConnectionRefused(error)) return 'free';
40+
return 'unknown';
3741
}
3842
return status === 401 ? 'guarded' : 'foreign';
3943
}
4044

45+
function isConnectionRefused(error: unknown): boolean {
46+
return (
47+
typeof error === 'object' &&
48+
error !== null &&
49+
'code' in error &&
50+
error.code === 'ECONNREFUSED'
51+
);
52+
}
53+
4154
function requestHealth(input: MiniAppPortProbeInput): Promise<number> {
4255
if (input.signal.aborted) return Promise.reject(input.signal.reason);
4356
return new Promise((resolve, reject) => {

‎packages/local-runtime-v2/src/service/miniapp/process/node-runtime/node-runtime.ts‎

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { MiniAppCleanupUnprovenError, markMiniAppRootTerminationProven } from '.
2020
import { sanitizeMiniAppRuntimeLogEvent } from '../../runtime-log.js';
2121
import type { MiniAppRunnerInitMessage } from './host-runner.js';
2222
import { waitForLoopbackReadiness } from './loopback-readiness.js';
23+
import { probeMiniAppPortOwnership } from './loopback-ownership.js';
2324
import { acquireMiniAppOwner, type MiniAppOwnerIdentity } from './owner-lease.js';
2425
import {
2526
createDirectNodeRootTerminator,
@@ -265,9 +266,14 @@ async function preparedRuntimeWithOwnership(
265266
): Promise<MiniAppNodeRuntime> {
266267
// One credential per process generation: every port attempt reuses it.
267268
const loopbackToken = input.options.makeLoopbackToken();
269+
const derivedPort = deterministicPort(input);
270+
const firstCandidatePort = candidatePort(input, 0);
271+
if (firstCandidatePort === derivedPort) {
272+
await refuseIfPortIsOwnedElsewhere(input, derivedPort);
273+
}
268274
let lastError: unknown;
269275
for (let index = 0; index < input.options.maxPortAttempts; index += 1) {
270-
const port = candidatePort(input, index);
276+
const port = index === 0 ? firstCandidatePort : candidatePort(input, index);
271277
const reservation = input.options.reservePort?.({
272278
pluginId: input.candidate.pluginId,
273279
port,
@@ -310,7 +316,11 @@ async function preparedRuntimeWithOwnership(
310316
attachStartupFailureDetail(error, failure);
311317
attempt?.startupErrorCapture.attachFailure(failure, error);
312318
lastError = failure;
313-
if (!retryablePortError(failure) || index + 1 >= input.options.maxPortAttempts) throw failure;
319+
if (!retryablePortError(failure)) throw failure;
320+
// Only this plugin's deterministic port identifies its cross-client owner. A guarded
321+
// listener on any fallback candidate can belong to a different Mini App.
322+
if (port === derivedPort) await refuseIfPortIsOwnedElsewhere(input, port);
323+
if (index + 1 >= input.options.maxPortAttempts) throw failure;
314324
}
315325
}
316326
throw (
@@ -319,6 +329,31 @@ async function preparedRuntimeWithOwnership(
319329
);
320330
}
321331

332+
async function refuseIfPortIsOwnedElsewhere(
333+
input: Parameters<typeof prepareRuntime>[0],
334+
port: number,
335+
): Promise<void> {
336+
throwIfAborted(input.signal);
337+
const ownership = await probeMiniAppPortOwnership({
338+
port,
339+
timeoutMs: input.options.tcpConnectTimeoutMs,
340+
signal: input.signal,
341+
});
342+
throwIfAborted(input.signal);
343+
if (ownership === 'unknown') {
344+
throw new MiniAppNodeRuntimeError(
345+
'RUNNER_START_FAILED',
346+
`Unable to verify whether Mini App ${input.candidate.pluginId} is already running in another client`,
347+
);
348+
}
349+
if (ownership === 'guarded') {
350+
throw new MiniAppNodeRuntimeError(
351+
'OWNED_ELSEWHERE',
352+
ownerHeldDetail(undefined, input.candidate.pluginId),
353+
);
354+
}
355+
}
356+
322357
async function cleanupFailedAttempt(
323358
attempt: Attempt | undefined,
324359
cause: unknown,
@@ -865,12 +900,7 @@ function needsConnectorChannel(candidate: ProcessMiniAppCandidate): boolean {
865900
}
866901

867902
function candidatePort(input: Parameters<typeof prepareRuntime>[0], index: number): number {
868-
const preferred = input.preferredPort;
869-
const digest = createHash('sha256').update(input.candidate.pluginId).digest();
870-
const base =
871-
preferred !== undefined && preferred >= PORT_MIN && preferred <= PORT_MAX
872-
? preferred
873-
: PORT_MIN + (digest.readUInt32BE(0) % PORT_COUNT);
903+
const base = deterministicPort(input);
874904
let available = 0;
875905
for (let offset = 0; offset < PORT_COUNT; offset += 1) {
876906
const port = PORT_MIN + ((base - PORT_MIN + offset) % PORT_COUNT);
@@ -881,6 +911,13 @@ function candidatePort(input: Parameters<typeof prepareRuntime>[0], index: numbe
881911
throw new MiniAppNodeRuntimeError('NO_CANDIDATE_PORT', 'No Host-approved port is available');
882912
}
883913

914+
function deterministicPort(input: Parameters<typeof prepareRuntime>[0]): number {
915+
const preferred = input.preferredPort;
916+
if (preferred !== undefined && preferred >= PORT_MIN && preferred <= PORT_MAX) return preferred;
917+
const digest = createHash('sha256').update(input.candidate.pluginId).digest();
918+
return PORT_MIN + (digest.readUInt32BE(0) % PORT_COUNT);
919+
}
920+
884921
function runnerEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
885922
const result: NodeJS.ProcessEnv = { ELECTRON_RUN_AS_NODE: '1' };
886923
for (const key of [

‎packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { spawn } from 'node:child_process';
22
import http from 'node:http';
3-
import { createServer as createNetServer, connect as connectNet } from 'node:net';
3+
import { createServer as createNetServer, connect as connectNet, type Socket } from 'node:net';
44
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
55
import { tmpdir } from 'node:os';
66
import { dirname, join } from 'node:path';
@@ -354,4 +354,29 @@ describe('Mini App port ownership', () => {
354354
child.kill('SIGKILL');
355355
}
356356
});
357+
358+
it('reports a stalled listener as unknown rather than free', async () => {
359+
const stalledPort = await freePort();
360+
const sockets = new Set<Socket>();
361+
const stalledServer = createNetServer((socket) => {
362+
sockets.add(socket);
363+
socket.once('close', () => sockets.delete(socket));
364+
});
365+
await new Promise<void>((resolve, reject) => {
366+
stalledServer.once('error', reject);
367+
stalledServer.listen(stalledPort, '127.0.0.1', resolve);
368+
});
369+
try {
370+
await expect(
371+
probeMiniAppPortOwnership({
372+
port: stalledPort,
373+
timeoutMs: 10,
374+
signal: new AbortController().signal,
375+
}),
376+
).resolves.toBe('unknown');
377+
} finally {
378+
for (const socket of sockets) socket.destroy();
379+
await new Promise<void>((resolve) => stalledServer.close(() => resolve()));
380+
}
381+
});
357382
});

‎packages/local-runtime-v2/test/unit/miniapp/node-runtime-credential.test.ts‎

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2+
import { createServer, type Server } from 'node:http';
23
import { tmpdir } from 'node:os';
34
import { dirname, join } from 'node:path';
45
import { EventEmitter } from 'node:events';
@@ -35,6 +36,7 @@ class ScriptedChild extends EventEmitter {
3536
private readonly onExit: () => void,
3637
/** A child that accepts the init message and never reports readiness. */
3738
private readonly silent = false,
39+
private readonly beforeStart?: (init: MiniAppRunnerInitMessage) => Promise<void>,
3840
) {
3941
super();
4042
}
@@ -45,6 +47,7 @@ class ScriptedChild extends EventEmitter {
4547
this.received.push(init);
4648
if (this.silent) return true;
4749
try {
50+
await this.beforeStart?.(init);
4851
this.runner = await startMiniAppModule(init, {
4952
loadModule: async () => ({
5053
start: async (context: { readonly listen: { host: string; port: number } }) => {
@@ -67,7 +70,11 @@ class ScriptedChild extends EventEmitter {
6770
});
6871
} catch (error) {
6972
callback?.(error as Error);
70-
this.emit('message', { type: 'miniapp:error', code: 'RUNNER_START_FAILED', errorText: String(error) });
73+
const code =
74+
typeof error === 'object' && error !== null && 'code' in error && error.code === 'EADDRINUSE'
75+
? 'EADDRINUSE'
76+
: 'RUNNER_START_FAILED';
77+
this.emit('message', { type: 'miniapp:error', code, errorText: String(error) });
7178
return false;
7279
}
7380
callback?.(null);
@@ -230,6 +237,105 @@ describe('Mini App process generations', () => {
230237
});
231238

232239
describe('Mini App ownership before spawn', () => {
240+
it('re-probes after a bind race and does not retry on another port', async () => {
241+
const dataDir = await mkdtemp(join(tmpdir(), 'miniapp external race '));
242+
let guardedServer: Server | undefined;
243+
const child = new ScriptedChild(() => undefined, false, async (init) => {
244+
guardedServer = createServer((_request, response) => {
245+
response.writeHead(401).end();
246+
});
247+
await new Promise<void>((resolve, reject) => {
248+
guardedServer?.once('error', reject);
249+
guardedServer?.listen(init.listen.port, '127.0.0.1', resolve);
250+
});
251+
});
252+
const adapter = createMiniAppNodeRuntimeAdapter({
253+
resolvePluginDataDir: () => dataDir,
254+
verifyCandidate: async () => true,
255+
materializePackage: async ({ targetRoot }) => {
256+
const target = join(targetRoot, 'miniapp', 'node', 'server.js');
257+
await mkdir(dirname(target), { recursive: true });
258+
await writeFile(target, 'export const start = async () => undefined;\n');
259+
},
260+
isPortReserved: () => false,
261+
maxPortAttempts: 1,
262+
spawn: () => child as unknown as ChildProcess,
263+
createTerminator: () => ({ stop: async () => ({ proven: true }) }) as never,
264+
});
265+
266+
try {
267+
await expect(
268+
adapter.prepare({
269+
candidate: candidate(),
270+
processGeneration: 'generation-bind-race',
271+
preferredPort: 56_000,
272+
signal: new AbortController().signal,
273+
onLog: () => undefined,
274+
}),
275+
).rejects.toMatchObject({ code: 'OWNED_ELSEWHERE' });
276+
expect(child.received).toHaveLength(1);
277+
} finally {
278+
guardedServer?.closeAllConnections();
279+
if (guardedServer) {
280+
await new Promise<void>((resolve) => guardedServer?.close(() => resolve()));
281+
}
282+
await rm(dataDir, { recursive: true, force: true });
283+
}
284+
});
285+
286+
it('refuses a guarded derived port owned by another client before retrying on a new port', async () => {
287+
const dataDir = await mkdtemp(join(tmpdir(), 'miniapp external owner '));
288+
let guardedServer: Server | undefined;
289+
let guardedPort: number | undefined;
290+
for (let port = 55_000; port < 56_000 && guardedServer === undefined; port += 1) {
291+
const server = createServer((_request, response) => {
292+
response.writeHead(401).end();
293+
});
294+
try {
295+
await new Promise<void>((resolve, reject) => {
296+
server.once('error', reject);
297+
server.listen(port, '127.0.0.1', resolve);
298+
});
299+
guardedServer = server;
300+
guardedPort = port;
301+
} catch {
302+
server.closeAllConnections();
303+
}
304+
}
305+
if (!guardedServer || guardedPort === undefined) throw new Error('Could not reserve a test port');
306+
307+
const child = new ScriptedChild(() => undefined);
308+
const adapter = createMiniAppNodeRuntimeAdapter({
309+
resolvePluginDataDir: () => dataDir,
310+
verifyCandidate: async () => true,
311+
materializePackage: async ({ targetRoot }) => {
312+
const target = join(targetRoot, 'miniapp', 'node', 'server.js');
313+
await mkdir(dirname(target), { recursive: true });
314+
await writeFile(target, 'export const start = async () => undefined;\n');
315+
},
316+
isPortReserved: () => false,
317+
spawn: () => child as unknown as ChildProcess,
318+
createTerminator: () => ({ stop: async () => ({ proven: true }) }) as never,
319+
});
320+
321+
try {
322+
const preparation = adapter.prepare({
323+
candidate: candidate(),
324+
processGeneration: 'generation-external-owner',
325+
preferredPort: guardedPort,
326+
signal: new AbortController().signal,
327+
onLog: () => undefined,
328+
});
329+
330+
await expect(preparation).rejects.toMatchObject({ code: 'OWNED_ELSEWHERE' });
331+
expect(child.received).toHaveLength(0);
332+
} finally {
333+
guardedServer.closeAllConnections();
334+
await new Promise<void>((resolve) => guardedServer.close(() => resolve()));
335+
await rm(dataDir, { recursive: true, force: true });
336+
}
337+
});
338+
233339
it('refuses a Mini App another client owns, without spawning a process', async () => {
234340
const dataDir = await mkdtemp(join(tmpdir(), 'miniapp owned '));
235341
const build = (child: ScriptedChild) =>

0 commit comments

Comments
 (0)