11import * as path from 'path' ;
22import * as os from 'os' ;
33import * as crypto from 'crypto' ;
4+ import * as net from 'net' ;
45import * as fse from 'fs-extra' ;
56import { request } from '@playwright/test' ;
67import {
@@ -40,14 +41,58 @@ export interface SynapseInstance extends SynapseConfig {
4041}
4142
4243const synapses = new Map < string , SynapseInstance > ( ) ;
44+ const dynamicHostPortStartAttempts = 5 ;
45+
46+ function findAvailablePort ( preferred ?: number ) : Promise < number > {
47+ return new Promise ( ( resolve , reject ) => {
48+ let server = net . createServer ( ) ;
49+
50+ server . on ( 'error' , ( error : NodeJS . ErrnoException ) => {
51+ server . close ( ) ;
52+ if ( preferred != null && error . code === 'EADDRINUSE' ) {
53+ findAvailablePort ( undefined ) . then ( resolve , reject ) ;
54+ return ;
55+ }
56+ reject ( error ) ;
57+ } ) ;
58+
59+ server . listen ( preferred ?? 0 , '127.0.0.1' , ( ) => {
60+ let address = server . address ( ) ;
61+ if ( ! address || typeof address === 'string' ) {
62+ server . close ( ( ) =>
63+ reject ( new Error ( 'Could not determine available port' ) ) ,
64+ ) ;
65+ return ;
66+ }
67+ let { port } = address ;
68+ server . close ( ( error ) => {
69+ if ( error ) {
70+ reject ( error ) ;
71+ return ;
72+ }
73+ resolve ( port ) ;
74+ } ) ;
75+ } ) ;
76+ } ) ;
77+ }
4378
4479function randB64Bytes ( numBytes : number ) : string {
4580 return crypto . randomBytes ( numBytes ) . toString ( 'base64' ) . replace ( / = * $ / , '' ) ;
4681}
4782
83+ function isPortBindError ( error : unknown ) : boolean {
84+ let message = error instanceof Error ? error . message : String ( error ) ;
85+ return / a d d r e s s a l r e a d y i n u s e | p o r t i s a l r e a d y a l l o c a t e d / i. test ( message ) ;
86+ }
87+
4888export async function cfgDirFromTemplate (
4989 template : string ,
5090 dataDir ?: string ,
91+ options ?: {
92+ publicBaseUrl ?: string ;
93+ host ?: string ;
94+ port ?: number ;
95+ } ,
5196) : Promise < SynapseConfig > {
5297 const templateDir = path . join ( __dirname , template ) ;
5398
@@ -69,7 +114,9 @@ export async function cfgDirFromTemplate(
69114 const macaroonSecret = randB64Bytes ( 16 ) ;
70115 const formSecret = randB64Bytes ( 16 ) ;
71116
72- const baseUrl = `http://${ SYNAPSE_IP_ADDRESS } :${ SYNAPSE_PORT } ` ;
117+ const host = options ?. host ?? SYNAPSE_IP_ADDRESS ;
118+ const port = options ?. port ?? SYNAPSE_PORT ;
119+ const baseUrl = options ?. publicBaseUrl ?? `http://${ host } :${ port } ` ;
73120
74121 // now copy homeserver.yaml, applying substitutions
75122 console . log ( `Gen ${ path . join ( templateDir , 'homeserver.yaml' ) } ` ) ;
@@ -95,8 +142,8 @@ export async function cfgDirFromTemplate(
95142 ) ;
96143
97144 return {
98- port : SYNAPSE_PORT ,
99- host : SYNAPSE_IP_ADDRESS ,
145+ port,
146+ host,
100147 baseUrl,
101148 configDir,
102149 registrationSecret,
@@ -136,51 +183,80 @@ export async function synapseStart(
136183 }
137184 await Promise . allSettled ( stopPromises ) ;
138185 }
139- const synCfg = await cfgDirFromTemplate (
140- opts ?. template ?? 'test' ,
141- opts ?. dataDir ,
142- ) ;
143- let containerName =
144- opts ?. containerName ||
145- ( isEnvironmentMode ( )
146- ? getSynapseContainerName ( )
147- : path . basename ( synCfg . configDir ) ) ;
148- console . log (
149- `Starting synapse with config dir ${ synCfg . configDir } in container ${ containerName } ...` ,
186+ let useDynamicHostPort = Boolean (
187+ isEnvironmentMode ( ) || opts ?. dynamicHostPort ,
150188 ) ;
151189 await dockerCreateNetwork ( { networkName : 'boxel' } ) ;
152190
153- let dockerParams : string [ ] = [
154- '--rm' ,
155- '-v' ,
156- `${ synCfg . configDir } :/data` ,
157- '-v' ,
158- `${ path . join ( __dirname , 'templates' ) } :/custom/templates/` ,
159- ] ;
160- if ( isEnvironmentMode ( ) || opts ?. dynamicHostPort ) {
161- // Dynamic host port, with fixed container IP only when not running in branch mode
162- if ( ! isEnvironmentMode ( ) ) {
163- dockerParams . push ( `--ip=${ synCfg . host } ` ) ;
164- }
165- dockerParams . push ( '-p' , '0:8008/tcp' , '--network=boxel' ) ;
166- } else {
167- dockerParams . push (
168- `--ip=${ synCfg . host } ` ,
169- '-p' ,
170- `${ synCfg . port } :8008/tcp` ,
171- '--network=boxel' ,
191+ let hostPort = SYNAPSE_PORT ;
192+ let synCfg ! : SynapseConfig ;
193+ let containerName ! : string ;
194+ let synapseId ! : string ;
195+ let attempts = useDynamicHostPort ? dynamicHostPortStartAttempts : 1 ;
196+
197+ for ( let attempt = 1 ; attempt <= attempts ; attempt ++ ) {
198+ hostPort = useDynamicHostPort ? await findAvailablePort ( ) : SYNAPSE_PORT ;
199+ synCfg = await cfgDirFromTemplate ( opts ?. template ?? 'test' , opts ?. dataDir , {
200+ host : useDynamicHostPort ? '127.0.0.1' : SYNAPSE_IP_ADDRESS ,
201+ port : hostPort ,
202+ publicBaseUrl : `http://localhost:${ hostPort } ` ,
203+ } ) ;
204+ containerName =
205+ opts ?. containerName ||
206+ ( isEnvironmentMode ( )
207+ ? getSynapseContainerName ( )
208+ : path . basename ( synCfg . configDir ) ) ;
209+ console . log (
210+ `Starting synapse with config dir ${ synCfg . configDir } in container ${ containerName } ...` ,
172211 ) ;
173- }
174212
175- const synapseId = await dockerRun ( {
176- image : 'matrixdotorg/synapse:v1.126.0' ,
177- containerName,
178- dockerParams,
179- applicationParams : [ 'run' ] ,
180- runAsUser : true ,
181- } ) ;
213+ let dockerParams : string [ ] = [
214+ '--rm' ,
215+ '-v' ,
216+ `${ synCfg . configDir } :/data` ,
217+ '-v' ,
218+ `${ path . join ( __dirname , 'templates' ) } :/custom/templates/` ,
219+ ] ;
220+ if ( useDynamicHostPort ) {
221+ // In dynamic-host-port mode multiple harnesses may run concurrently, so
222+ // we must not claim the shared fixed Synapse container IP.
223+ dockerParams . push ( '-p' , `${ hostPort } :8008/tcp` , '--network=boxel' ) ;
224+ } else {
225+ dockerParams . push (
226+ `--ip=${ synCfg . host } ` ,
227+ '-p' ,
228+ `${ synCfg . port } :8008/tcp` ,
229+ '--network=boxel' ,
230+ ) ;
231+ }
182232
183- console . log ( `Started synapse with id ${ synapseId } on port ${ synCfg . port } ` ) ;
233+ try {
234+ synapseId = await dockerRun ( {
235+ image : 'matrixdotorg/synapse:v1.126.0' ,
236+ containerName,
237+ dockerParams,
238+ applicationParams : [ 'run' ] ,
239+ runAsUser : true ,
240+ } ) ;
241+ break ;
242+ } catch ( error ) {
243+ if (
244+ ! useDynamicHostPort ||
245+ ! isPortBindError ( error ) ||
246+ attempt === attempts
247+ ) {
248+ throw error ;
249+ }
250+ console . warn (
251+ `Synapse host port ${ hostPort } was claimed before Docker bound it; retrying (${ attempt } /${ attempts } )...` ,
252+ ) ;
253+ if ( ! opts ?. dataDir ) {
254+ await fse . remove ( synCfg . configDir ) ;
255+ }
256+ }
257+ }
258+
259+ console . log ( `Started synapse with id ${ synapseId } on port ${ hostPort } ` ) ;
184260
185261 // Await Synapse healthcheck
186262 await dockerExec ( {
@@ -199,9 +275,13 @@ export async function synapseStart(
199275 ] ,
200276 } ) ;
201277
202- let hostPort = synCfg . port ;
203- if ( isEnvironmentMode ( ) || opts ?. dynamicHostPort ) {
204- hostPort = await resolveHostPort ( synapseId ) ;
278+ if ( useDynamicHostPort ) {
279+ let resolvedPort = await resolveHostPort ( synapseId ) ;
280+ if ( resolvedPort !== hostPort ) {
281+ throw new Error (
282+ `Synapse started on unexpected host port ${ resolvedPort } ; expected ${ hostPort } ` ,
283+ ) ;
284+ }
205285 console . log ( `Synapse dynamic host port: ${ hostPort } ` ) ;
206286 }
207287
0 commit comments