@@ -30,6 +30,81 @@ private struct HostLauncher {
3030
3131 let client = LocalSocketClient ( )
3232
33+ private final class LogCapture {
34+ let store : HostLogStore
35+ let process : Process
36+ let writer : FileHandle
37+
38+ init ( environment: [ String : String ] ) throws {
39+ store = try HostLogStore ( environment: environment)
40+ try store. prepare ( )
41+ let pipe = Pipe ( )
42+ process = Process ( )
43+ process. executableURL = try Self . runningCLIURL ( )
44+ process. arguments = [ " __host-log-writer " ]
45+ process. environment = [
46+ " HEADLESS_INTERNAL_LOG_WRITER " : " 1 " ,
47+ " HEADLESS_INTERNAL_LOG_PATH " : store. url. path,
48+ " HEADLESS_INTERNAL_LOG_MAX_BYTES " : String ( store. maximumBytes) ,
49+ ]
50+ process. standardInput = pipe. fileHandleForReading
51+ process. standardOutput = FileHandle . nullDevice
52+ process. standardError = FileHandle . nullDevice
53+ writer = pipe. fileHandleForWriting
54+ do {
55+ try process. run ( )
56+ try pipe. fileHandleForReading. close ( )
57+ } catch {
58+ try ? pipe. fileHandleForReading. close ( )
59+ try ? pipe. fileHandleForWriting. close ( )
60+ throw error
61+ }
62+ }
63+
64+ func closeParentWriter( ) {
65+ try ? writer. close ( )
66+ }
67+
68+ func waitForDrain( ) {
69+ closeParentWriter ( )
70+ let deadline = Date ( ) . addingTimeInterval ( 1 )
71+ while process. isRunning, Date ( ) < deadline {
72+ Thread . sleep ( forTimeInterval: 0.01 )
73+ }
74+ if process. isRunning {
75+ process. terminate ( )
76+ let terminationDeadline = Date ( ) . addingTimeInterval ( 1 )
77+ while process. isRunning, Date ( ) < terminationDeadline {
78+ Thread . sleep ( forTimeInterval: 0.01 )
79+ }
80+ }
81+ if process. isRunning { _ = kill ( process. processIdentifier, SIGKILL) }
82+ process. waitUntilExit ( )
83+ }
84+
85+ var diagnostic : HostLaunchDiagnostic {
86+ HostLaunchDiagnostic ( path: store. url. path, tail: store. diagnosticTail ( ) )
87+ }
88+
89+ private static func runningCLIURL( ) throws -> URL {
90+ #if os(Linux)
91+ let candidate = URL ( fileURLWithPath: " /proc/self/exe " ) . resolvingSymlinksInPath ( )
92+ #else
93+ var requiredSize : UInt32 = 0
94+ _ = _NSGetExecutablePath ( nil , & requiredSize)
95+ var buffer = [ CChar] ( repeating: 0 , count: Int ( requiredSize) )
96+ guard _NSGetExecutablePath ( & buffer, & requiredSize) == 0 else {
97+ throw HostLogError . operationFailed ( " writer executable resolution " )
98+ }
99+ let candidate = URL ( fileURLWithPath: String ( cString: buffer) ) . standardizedFileURL
100+ #endif
101+ guard FileManager . default. isExecutableFile ( atPath: candidate. path) else {
102+ throw HostLogError . operationFailed ( " writer executable resolution " )
103+ }
104+ return candidate
105+ }
106+ }
107+
33108 func ping( ) -> CommandResponse ? {
34109 try ? client. send ( CommandRequest ( command: . ping) , timeout: 0.5 )
35110 }
@@ -75,22 +150,17 @@ private struct HostLauncher {
75150 environment. removeValue ( forKey: headlessNavigationAllowlistEnvironmentKey)
76151 }
77152 process. environment = environment
153+ let logCapture = try LogCapture ( environment: environment)
78154 let ownerPipe = supervised ? Pipe ( ) : nil
79155 process. standardInput = ownerPipe? . fileHandleForReading ?? FileHandle . nullDevice
80- if let hostLog = environment [ " HEADLESS_HOST_LOG " ] , hostLog. hasPrefix ( " / " ) {
81- let logURL = URL ( fileURLWithPath: hostLog)
82- FileManager . default. createFile ( atPath: logURL. path, contents: nil )
83- let handle = try FileHandle ( forWritingTo: logURL)
84- process. standardOutput = handle
85- process. standardError = handle
86- } else {
87- process. standardOutput = FileHandle . nullDevice
88- process. standardError = FileHandle . nullDevice
89- }
156+ process. standardOutput = logCapture. writer
157+ process. standardError = logCapture. writer
90158 do {
91159 try process. run ( )
160+ logCapture. closeParentWriter ( )
92161 try ownerPipe? . fileHandleForReading. close ( )
93162 } catch {
163+ logCapture. waitForDrain ( )
94164 try ? ownerPipe? . fileHandleForReading. close ( )
95165 try ? ownerPipe? . fileHandleForWriting. close ( )
96166 throw error
@@ -117,13 +187,15 @@ private struct HostLauncher {
117187 if !process. isRunning {
118188 try ? ownerPipe? . fileHandleForWriting. close ( )
119189 process. waitUntilExit ( )
120- throw HostLaunchError . exited ( process. terminationStatus)
190+ logCapture. waitForDrain ( )
191+ throw HostLaunchError . exited ( process. terminationStatus, logCapture. diagnostic)
121192 }
122193 Thread . sleep ( forTimeInterval: 0.05 )
123194 } while Date ( ) < deadline
124195 try ? ownerPipe? . fileHandleForWriting. close ( )
125196 terminateAndReap ( process)
126- throw HostLaunchError . timedOut
197+ logCapture. waitForDrain ( )
198+ throw HostLaunchError . timedOut ( logCapture. diagnostic)
127199 }
128200
129201 func waitForSupervisedHost( _ launch: Launch ) -> Int32 {
@@ -228,12 +300,22 @@ private struct HostLauncher {
228300 }
229301}
230302
303+ private struct HostLaunchDiagnostic {
304+ let path : String
305+ let tail : String ?
306+
307+ var description : String {
308+ guard let tail, !tail. isEmpty else { return " Host log: \( path) " }
309+ return " Host log: \( path) \n Recent host output: \n \( tail) "
310+ }
311+ }
312+
231313private enum HostLaunchError : Error , CustomStringConvertible {
232314 case notFound
233315 case alreadyRunning
234316 case ownershipMismatch
235- case timedOut
236- case exited( Int32 )
317+ case timedOut( HostLaunchDiagnostic )
318+ case exited( Int32 , HostLaunchDiagnostic )
237319 case allowlistMismatch( running: [ String ] , requested: [ String ] )
238320
239321 var description : String {
@@ -243,8 +325,10 @@ private enum HostLaunchError: Error, CustomStringConvertible {
243325 return " A shared Headless host is already running. Stop it before starting a supervised host. "
244326 case . ownershipMismatch:
245327 return " A different Headless host answered during supervised startup. "
246- case . timedOut: return " Headless host did not become ready within 8 seconds. "
247- case . exited( let status) : return " Headless host exited during startup (status \( status) ). "
328+ case . timedOut( let diagnostic) :
329+ return " Headless host did not become ready within 8 seconds. \n \( diagnostic. description) "
330+ case . exited( let status, let diagnostic) :
331+ return " Headless host exited during startup (status \( status) ). \n \( diagnostic. description) "
248332 case . allowlistMismatch( let running, let requested) :
249333 let runningText = running. isEmpty ? " unrestricted " : running. joined ( separator: " , " )
250334 return " The running host navigation allowlist ( \( runningText) ) does not match ( \( requested. joined ( separator: " , " ) ) ). Run `headless stop` first. "
@@ -321,7 +405,30 @@ private enum CredentialBrokerLaunchError: Error, CustomStringConvertible {
321405 }
322406}
323407
408+ private func runInternalHostLogWriterIfRequested( ) -> Bool {
409+ guard Array ( CommandLine . arguments. dropFirst ( ) ) == [ " __host-log-writer " ] else { return false }
410+ let environment = ProcessInfo . processInfo. environment
411+ guard environment [ " HEADLESS_INTERNAL_LOG_WRITER " ] == " 1 " ,
412+ let path = environment [ " HEADLESS_INTERNAL_LOG_PATH " ] , path. hasPrefix ( " / " ) ,
413+ let rawMaximum = environment [ " HEADLESS_INTERNAL_LOG_MAX_BYTES " ] ,
414+ let maximum = Int ( rawMaximum) else {
415+ fputs ( " headless: internal log writer authorization failed \n " , stderr)
416+ exit ( 64 )
417+ }
418+ do {
419+ let store = try HostLogStore (
420+ environment: [ " HEADLESS_HOST_LOG " : path] , maximumBytes: maximum
421+ )
422+ try store. consume ( . standardInput)
423+ } catch {
424+ fputs ( " headless: internal log writer failed \n " , stderr)
425+ exit ( 74 )
426+ }
427+ return true
428+ }
429+
324430do {
431+ if runInternalHostLogWriterIfRequested ( ) { exit ( 0 ) }
325432 let invocation = try CLIParser ( ) . parse ( Array ( CommandLine . arguments. dropFirst ( ) ) )
326433 if let local = invocation. local {
327434 switch local {
@@ -342,6 +449,10 @@ do {
342449 " supported " : . bool( true ) , " transport " : . string( " native-webkit " ) ,
343450 ] ) )
344451 #endif
452+ case . doctor:
453+ let report = try HeadlessDoctor ( ) . run ( )
454+ printJSON ( report. document)
455+ if report. hasFailures { exit ( 69 ) }
345456 case . start( let presentation, let allowlist, let supervised) :
346457 let launch = try HostLauncher ( ) . start (
347458 presentation: presentation, allowlist: allowlist, supervised: supervised
@@ -423,6 +534,13 @@ do {
423534 )
424535 try ? printResponse ( response)
425536 exit ( 69 )
537+ } catch let error as HostLogError {
538+ let response = CommandResponse . failure (
539+ id: " unknown " , code: " HOST_LOG_UNAVAILABLE " , message: error. description,
540+ suggestion: " Check the private runtime directory or HEADLESS_HOST_LOG path. "
541+ )
542+ try ? printResponse ( response)
543+ exit ( 69 )
426544} catch let error as SettingsError {
427545 let code : String
428546 switch error {
0 commit comments