@@ -145,144 +145,172 @@ final class LocalStaticHTTPServer {
145145 }
146146}
147147
148- // Start HTTP (or HTTPS if tlsIdentity is provided) on given port. Serves static files from rootDir.
149- func start( host: NWEndpoint . Host = . ipv4( IPv4Address ( " 127.0.0.1 " ) !) ,
150- port: UInt16 = 7404 ,
151- rootDir: URL ,
152- tlsIdentity: sec_identity_t ? = nil ) throws -> UInt16
153- {
154- InstallLogger . shared. log ( " Starting HTTP server with port: \( port) , tlsIdentity: \( tlsIdentity != nil ? " present " : " nil " ) " )
155- self . rootDirectory = rootDir
156- self . serverStarted = false
157-
158- // Clear any existing connections
159- connectionsLock. lock ( )
160- activeConnections. removeAll ( )
161- connectionsLock. unlock ( )
162-
163- // Create TCP params and attach TLS options if identity provided
164- let tcpOptions = NWProtocolTCP . Options ( )
165- let tlsOptions : NWProtocolTLS . Options ? = {
166- guard let identity = tlsIdentity else {
167- InstallLogger . shared. log ( " No TLS identity provided, using HTTP " )
168- return nil
169- }
170- InstallLogger . shared. log ( " Configuring TLS options with provided identity " )
171- let options = NWProtocolTLS . Options ( )
172- // configure TLS min/max
173- sec_protocol_options_set_min_tls_protocol_version ( options. securityProtocolOptions, . TLSv12)
174- sec_protocol_options_set_max_tls_protocol_version ( options. securityProtocolOptions, . TLSv13)
175- // set local identity (the sec_identity_t)
176- sec_protocol_options_set_local_identity ( options. securityProtocolOptions, identity)
177- return options
178- } ( )
179-
180- let params : NWParameters
181- if let tls = tlsOptions {
182- params = NWParameters ( tls: tls, tcp: tcpOptions)
183- isTLS = true
184- InstallLogger . shared. log ( " Server configured with TLS (HTTPS) " )
185- } else {
186- params = NWParameters ( tls: nil , tcp: tcpOptions)
187- isTLS = false
188- InstallLogger . shared. log ( " Server configured without TLS (HTTP) " )
148+ func start( host: NWEndpoint . Host = . ipv4( IPv4Address ( " 127.0.0.1 " ) !) ,
149+ port: UInt16 = 7404 ,
150+ rootDir: URL ,
151+ tlsIdentity: sec_identity_t ? = nil ) throws -> UInt16
152+ {
153+ InstallLogger . shared. log ( " Starting HTTP server with port: \( port) , tlsIdentity: \( tlsIdentity != nil ? " present " : " nil " ) " )
154+ self . rootDirectory = rootDir
155+ self . serverStarted = false
156+
157+ // If there is an existing listener, stop it first so the port gets freed.
158+ if listener != nil {
159+ InstallLogger . shared. log ( " An existing listener was found — stopping it before starting a new one " )
160+ stop ( )
161+ // give the OS a tiny moment to free the port
162+ Thread . sleep ( forTimeInterval: 0.05 )
163+ }
164+
165+ // (No longer blindly removing activeConnections here — stop() handles that)
166+
167+ // Create TCP params and attach TLS options if identity provided
168+ let tcpOptions = NWProtocolTCP . Options ( )
169+ let tlsOptions : NWProtocolTLS . Options ? = {
170+ guard let identity = tlsIdentity else {
171+ InstallLogger . shared. log ( " No TLS identity provided, using HTTP " )
172+ return nil
189173 }
174+ InstallLogger . shared. log ( " Configuring TLS options with provided identity " )
175+ let options = NWProtocolTLS . Options ( )
176+ sec_protocol_options_set_min_tls_protocol_version ( options. securityProtocolOptions, . TLSv12)
177+ sec_protocol_options_set_max_tls_protocol_version ( options. securityProtocolOptions, . TLSv13)
178+ sec_protocol_options_set_local_identity ( options. securityProtocolOptions, identity)
179+ return options
180+ } ( )
181+
182+ let params : NWParameters
183+ if let tls = tlsOptions {
184+ params = NWParameters ( tls: tls, tcp: tcpOptions)
185+ isTLS = true
186+ InstallLogger . shared. log ( " Server configured with TLS (HTTPS) " )
187+ } else {
188+ params = NWParameters ( tls: nil , tcp: tcpOptions)
189+ isTLS = false
190+ InstallLogger . shared. log ( " Server configured without TLS (HTTP) " )
191+ }
190192
191- let nwPort = NWEndpoint . Port ( rawValue: port) ?? NWEndpoint . Port ( integerLiteral: 0 )
192- let listener : NWListener
193+ let requestedPort = port
194+ var listener : NWListener
195+ // Try requested port first; if it fails try ephemeral port 0
196+ do {
197+ let nwPort = NWEndpoint . Port ( rawValue: requestedPort) ?? NWEndpoint . Port ( integerLiteral: 0 )
198+ listener = try NWListener ( using: params, on: nwPort)
199+ InstallLogger . shared. log ( " NWListener created successfully on port \( requestedPort) " )
200+ } catch {
201+ InstallLogger . shared. logWarning ( " NWListener init failed for port \( requestedPort) : \( error. localizedDescription) . Trying ephemeral port (0). " )
193202 do {
194- listener = try NWListener ( using: params, on: nwPort )
195- InstallLogger . shared. log ( " NWListener created successfully " )
203+ listener = try NWListener ( using: params, on: NWEndpoint . Port ( integerLiteral : 0 ) )
204+ InstallLogger . shared. log ( " NWListener created on ephemeral port (0) as fallback " )
196205 } catch {
197- InstallLogger . shared. logError ( " NWListener init failed: \( error. localizedDescription) " )
206+ InstallLogger . shared. logError ( " NWListener init failed even on ephemeral port : \( error. localizedDescription) " )
198207 throw InstallAppError . serverStartFailed ( " NWListener init failed: \( error. localizedDescription) " )
199208 }
209+ }
200210
201- // Create a semaphore to wait for server to start
202- let startSemaphore = DispatchSemaphore ( value: 0 )
203- var startError : Error ?
204-
205- listener. newConnectionHandler = { [ weak self] connection in
206- guard let self = self else { return }
207- InstallLogger . shared. logDebug ( " New connection received " )
208- self . handleConnection ( connection)
209- }
210-
211- listener. stateUpdateHandler = { newState in
212- switch newState {
213- case . ready:
214- InstallLogger . shared. log ( " Server listener is READY " )
215- self . serverStarted = true
216- startSemaphore. signal ( )
217- case . failed( let err) :
218- InstallLogger . shared. logError ( " Listener failed: \( String ( describing: err) ) " )
219- startError = err
220- startSemaphore. signal ( )
221- case . cancelled:
222- InstallLogger . shared. log ( " Listener cancelled " )
223- startSemaphore. signal ( )
224- case . waiting( let reason) :
225- InstallLogger . shared. log ( " Listener waiting: \( reason) " )
226- case . setup:
227- InstallLogger . shared. log ( " Listener in setup state " )
228- @unknown default :
229- InstallLogger . shared. log ( " Unknown listener state: \( newState) " )
230- }
231- }
232-
233- listener. start ( queue: queue)
234- self . listener = listener
235- InstallLogger . shared. log ( " Listener started on queue " )
211+ // Create a semaphore to wait for server to start
212+ let startSemaphore = DispatchSemaphore ( value: 0 )
213+ var startError : Error ?
214+
215+ listener. newConnectionHandler = { [ weak self] connection in
216+ guard let self = self else { return }
217+ InstallLogger . shared. logDebug ( " New connection received " )
218+ self . handleConnection ( connection)
219+ }
220+
221+ listener. stateUpdateHandler = { [ weak self] newState in
222+ guard let self = self else { return }
223+ switch newState {
224+ case . ready:
225+ InstallLogger . shared. log ( " Server listener is READY " )
226+ self . serverStarted = true
227+ startSemaphore. signal ( )
228+ case . failed( let err) :
229+ InstallLogger . shared. logError ( " Listener failed: \( String ( describing: err) ) " )
230+ startError = err
231+ startSemaphore. signal ( )
232+ case . cancelled:
233+ InstallLogger . shared. log ( " Listener cancelled " )
234+ // ensure serverStarted is false when cancelled
235+ self . serverStarted = false
236+ startSemaphore. signal ( )
237+ case . waiting( let reason) :
238+ InstallLogger . shared. log ( " Listener waiting: \( reason) " )
239+ case . setup:
240+ InstallLogger . shared. log ( " Listener in setup state " )
241+ @unknown default :
242+ InstallLogger . shared. log ( " Unknown listener state: \( newState) " )
243+ }
244+ }
245+
246+ listener. start ( queue: queue)
247+ self . listener = listener
248+ InstallLogger . shared. log ( " Listener started on queue " )
249+
250+ // Wait for server to start (with timeout)
251+ InstallLogger . shared. log ( " Waiting for server to become ready... " )
252+ let timeoutResult = startSemaphore. wait ( timeout: . now( ) + 10.0 )
253+
254+ if timeoutResult == . timedOut {
255+ InstallLogger . shared. logError ( " Server start timeout after 10 seconds " )
256+ // Cancel and cleanup local listener reference if still present
257+ listener. cancel ( )
258+ self . listener = nil
259+ throw InstallAppError . serverStartFailed ( " Server start timeout " )
260+ }
261+
262+ if let error = startError {
263+ InstallLogger . shared. logError ( " Server failed to start: \( error. localizedDescription) " )
264+ // Cancel and cleanup
265+ listener. cancel ( )
266+ self . listener = nil
267+ throw InstallAppError . serverStartFailed ( " Server failed to start: \( error. localizedDescription) " )
268+ }
269+
270+ if !serverStarted {
271+ InstallLogger . shared. logError ( " Server not started (serverStarted flag is false) " )
272+ listener. cancel ( )
273+ self . listener = nil
274+ throw InstallAppError . serverStartFailed ( " Server not started " )
275+ }
276+
277+ // if we started with port 0 (ephemeral), get the actual port
278+ let actualPort : UInt16
279+ if let localEndpoint = listener. port {
280+ actualPort = UInt16 ( localEndpoint. rawValue)
281+ InstallLogger . shared. log ( " Server bound to port: \( actualPort) " )
282+ } else {
283+ actualPort = requestedPort
284+ InstallLogger . shared. log ( " Using requested port: \( actualPort) " )
285+ }
236286
237- // Wait for server to start (with timeout)
238- InstallLogger . shared. log ( " Waiting for server to become ready... " )
239- let timeoutResult = startSemaphore. wait ( timeout: . now( ) + 10.0 )
240-
241- if timeoutResult == . timedOut {
242- InstallLogger . shared. logError ( " Server start timeout after 10 seconds " )
243- throw InstallAppError . serverStartFailed ( " Server start timeout " )
244- }
245-
246- if let error = startError {
247- InstallLogger . shared. logError ( " Server failed to start: \( error. localizedDescription) " )
248- throw InstallAppError . serverStartFailed ( " Server failed to start: \( error. localizedDescription) " )
249- }
250-
251- if !serverStarted {
252- InstallLogger . shared. logError ( " Server not started (serverStarted flag is false) " )
253- throw InstallAppError . serverStartFailed ( " Server not started " )
254- }
287+ InstallLogger . shared. logSuccess ( " Server successfully started on \( isTLS ? " https " : " http " ) ://127.0.0.1: \( actualPort) " )
288+ return actualPort
289+ }
255290
256- // if we started with port 0 (ephemeral), get the actual port
257- let actualPort : UInt16
258- if let localEndpoint = listener. port {
259- actualPort = UInt16 ( localEndpoint. rawValue)
260- InstallLogger . shared. log ( " Server bound to port: \( actualPort) " )
261- } else {
262- actualPort = port
263- InstallLogger . shared. log ( " Using requested port: \( actualPort) " )
264- }
291+ func stop( ) {
292+ InstallLogger . shared. log ( " Stopping HTTP server " )
265293
266- InstallLogger . shared. logSuccess ( " Server successfully started on \( isTLS ? " https " : " http " ) ://127.0.0.1: \( actualPort) " )
267- return actualPort
294+ connectionsLock. lock ( )
295+ // cancel all tracked connections
296+ for wrapper in activeConnections {
297+ wrapper. connection. cancel ( )
268298 }
299+ activeConnections. removeAll ( )
300+ connectionsLock. unlock ( )
269301
270- func stop( ) {
271- InstallLogger . shared. log ( " Stopping HTTP server " )
272-
273- connectionsLock. lock ( )
274- for wrapper in activeConnections {
275- wrapper. connection. cancel ( )
276- }
277-
278- activeConnections. removeAll ( )
279- connectionsLock. unlock ( )
280-
281- listener? . cancel ( )
282- listener = nil
283- serverStarted = false
302+ // cancel the listener and remove reference
303+ if let l = listener {
304+ InstallLogger . shared. log ( " Cancelling listener " )
305+ l. cancel ( )
306+ // give the OS a brief moment to actually close the socket so a subsequent start on the same port won't immediately fail
307+ Thread . sleep ( forTimeInterval: 0.05 )
284308 }
285309
310+ listener = nil
311+ serverStarted = false
312+ }
313+
286314 // Very minimal GET-only static file handler.
287315 private func handleConnection( _ connection: NWConnection ) {
288316 connectionsLock. lock ( )
@@ -905,3 +933,4 @@ public func installApp(from ipaURL: URL) throws {
905933 InstallLogger . shared. log ( " Summary written to: \( summaryURL. path) " )
906934}
907935
936+
0 commit comments