11import { mkdir , mkdtemp , rm , writeFile } from 'node:fs/promises' ;
2+ import { createServer , type Server } from 'node:http' ;
23import { tmpdir } from 'node:os' ;
34import { dirname , join } from 'node:path' ;
45import { 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
232239describe ( '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