-
Notifications
You must be signed in to change notification settings - Fork 1
/
Compiler.swift
884 lines (790 loc) · 36.5 KB
/
Compiler.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
//
// Compiler.swift
// DartSass
//
// Licensed under MIT (https://github.com/johnfairh/swift-sass/blob/main/LICENSE)
//
import struct Foundation.URL
import class Foundation.FileManager // cwd
import NIOCore
import NIOPosix // NIOThreadPool, NIOPipeBootstrap
import Logging
@_exported import Sass
// Compiler -- interface, control state machine
// CompilerChild -- Child process, NIO reads and writes
// CompilerRequest -- job state, many, managed by CompilerWork
/// XXX NIO bug? Workaround until Swift 6? :nodoc:
extension NIOPipeBootstrap: @unchecked Sendable {}
/// A Sass compiler that uses Dart Sass as an embedded child process.
///
/// The Dart Sass compiler is bundled with this package for macOS and Ubuntu 64-bit Linux.
/// For other platforms you need to supply this separately, see
/// [the readme](https://github.com/johnfairh/swift-sass/blob/main/README.md).
///
/// Some debug logging is available via a [swift-log](https://github.com/apple/swift-log) `Logger`
/// that produces goodpath protocol and compiler lifecycle tracing at `Logger.Level.debug` log level,
/// approx 300 bytes per compile request, and protocol and lifecycle error reporting at
/// `Logger.Level.debug` log level for conditions that are also reported through errors thrown from
/// some API.
///
/// You must shut down the compiler using `shutdownGracefully(...)` before the last reference
/// to the object is released otherwise the program will exit.
///
/// ## Custom importer resolution
///
/// Dart Sass uses a different algorithm to LibSass for processing imports. Each stylesheet is associated
/// with the importer that loaded it -- this may be an internal or hidden filesystem importer. Import resolution
/// then goes:
/// * Consult the stylesheet's associated importer.
/// * Consult every `DartSass.ImportResolver` given to the compiler, first the global list then the
/// per-compilation list, in order within each list.
public actor Compiler {
private let eventLoopGroup: any EventLoopGroup
enum State {
/// No child, new jobs wait for state change.
case initializing
/// Child up, checking it's working before accepting compilations.
case checking(CompilerChild)
/// Child is running and accepting compilation jobs.
case running(CompilerChild)
/// Child is broken. Fail new jobs with the error. Reinit permitted.
case broken(any Error)
/// System is shutting down, ongoing jobs will complete but no new. XXX can be ->init too
/// Shutdown will be done when state changes.
case quiescing(CompilerChild)
/// Compiler is shut down. Fail new jobs.
case shutdown
var child: CompilerChild? {
switch self {
case .checking(let c), .running(let c), .quiescing(let c): return c
case .initializing, .broken, .shutdown: return nil
}
}
}
/// Compiler process state. Internal for test access.
private(set) var state: State
/// Jobs waiting on compiler state change.
private var stateWaitingQueue: ContinuationQueue
/// Change the compiler state and resume anyone waiting.
private func setState(_ state: State, fn: String = #function) {
// debug("\(fn): \(self.state) -> \(state)")
self.state = state
Task.detached { await self.stateWaitingQueue.kick() }
}
/// Suspend the current task until the compiler state changes
private func waitForStateChange() async {
await stateWaitingQueue.wait()
}
private var runTask: Task<Void, Never>?
/// Number of times we've tried to start the embedded Sass compiler.
private(set) var startCount: Int
/// The path of the compiler program
private let embeddedCompilerFileURL: URL
/// Its arguments
private let embeddedCompilerFileArgs: [String]
/// Fixed settings for the compiler
let settings: Settings
/// Most recently received version of compiler
private var versions: Versions?
/// Active compilation work indexed by RequestID
var activeRequests: [UInt32 : any CompilerRequest]
/// Task waiting for quiesce
var quiesceContinuation: CheckedContinuation<Void, Never>?
/// Use the bundled Dart Sass compiler as the Sass compiler.
///
/// The bundled Dart Sass compiler is built on latest macOS (14) or Ubuntu (20.04).
/// If you are running on another operating system then use `init(eventLoopGroup:embeddedCompilerFileURL:embeddedCompilerFileArguments:timeout:messageStyle:verboseDeprecations:deprecationControl:warningLevel:importers:functions:)`
/// supplying the path of the correct Dart Sass compiler.
///
/// Initialization continues asynchronously after the initializer completes; failures are reported
/// when the compiler is next used.
///
/// You must shut down the compiler with `shutdownGracefully()` before letting it
/// go out of scope.
///
/// - parameter eventLoopGroup: NIO `EventLoopGroup` to use. Default uses the NIO
/// shared `EventLoopGroup`.
/// - parameter timeout: Maximum time in seconds allowed for the embedded
/// compiler to compile a stylesheet. Detects hung compilers. Default is a minute; set
/// -1 to disable timeouts.
/// - parameter messageStyle: Style for diagnostic message descriptions. Default is `.plain`.
/// - parameter verboseDeprecations: Control for deprecation warning messages.
/// If `false` then the compiler will send only a few deprecation warnings of the same type.
/// Default is `false` meaning repeated deprecation warnings _are_ suppressed.
/// - parameter deprecationControl: Fine-grained control over how each deprecated feature
/// is handled. Default is to issue a warning when any deprecated feature is used.
/// - parameter warningLevel: Control for warning messages from Sass files. Default is `.all`
/// meaning all warnings mentioned in Sass files are produced.
/// - parameter importers: Rules for resolving `@import` that cannot be satisfied relative to
/// the source file's URL, used for all this compiler's compilations.
/// - parameter functions: Sass functions available to all this compiler's compilations.
/// - throws: `LifecycleError` if the program can't be found.
public init(eventLoopGroup: any EventLoopGroup = MultiThreadedEventLoopGroup.singleton,
timeout: Int = 60,
messageStyle: CompilerMessageStyle = .plain,
verboseDeprecations: Bool = false,
deprecationControl: DeprecationControl = DeprecationControl(),
warningLevel: CompilerWarningLevel = .all,
importers: [ImportResolver] = [],
functions: SassFunctionMap = [:]) throws {
let (url, args) = try DartSassEmbedded.getURLAndArgs()
self.init(eventLoopGroup: eventLoopGroup,
embeddedCompilerFileURL: url,
embeddedCompilerFileArguments: args,
timeout: timeout,
messageStyle: messageStyle,
verboseDeprecations: verboseDeprecations,
deprecationControl: deprecationControl,
warningLevel: warningLevel,
importers: importers,
functions: functions)
}
/// Use a program as the Sass embedded compiler.
///
/// Initialization continues asynchronously after the initializer returns; failures are reported
/// when the compiler is next used.
///
/// You must shut down the compiler with `shutdownGracefully()` before letting it
/// go out of scope.
///
/// - parameter eventLoopGroup: NIO `EventLoopGroup` to use. Default uses the NIO
/// shared `EventLoopGroup`.
/// - parameter embeddedCompilerFileURL: Path of the `sass` program
/// or something else that speaks the Sass embedded protocol. Check [the readme](https://github.com/johnfairh/swift-sass/blob/main/README.md)
/// for the supported protocol versions.
/// - parameter embeddedCompilerFileArguments: Any arguments to be passed to the
/// `embeddedCompilerFileURL` program. Default none.
/// - parameter timeout: Maximum time in seconds allowed for the embedded
/// compiler to compile a stylesheet. Detects hung compilers. Default is a minute; set
/// -1 to disable timeouts.
/// - parameter messageStyle: Style for diagnostic message descriptions. Default is `.plain`.
/// - parameter verboseDeprecations: Control for deprecation warning messages.
/// If `false` then the compiler will send only a few deprecation warnings of the same type.
/// Default is `false` meaning repeated deprecation warnings _are_ suppressed.
/// - parameter deprecationControl: Fine-grained control over how each deprecated feature
/// is handled. Default is to issue a warning when any deprecated feature is used.
/// - parameter warningLevel: Control for warning messages from Sass files. Default is `.all`
/// meaning all warnings mentioned in Sass files are produced.
/// - parameter importers: Rules for resolving `@import` that cannot be satisfied relative to
/// the source file's URL, used for all this compiler's compilations.
/// - parameter functions: Sass functions available to all this compiler's compilations.
public init(eventLoopGroup: any EventLoopGroup = MultiThreadedEventLoopGroup.singleton,
embeddedCompilerFileURL: URL,
embeddedCompilerFileArguments: [String] = [],
timeout: Int = 60,
messageStyle: CompilerMessageStyle = .plain,
verboseDeprecations: Bool = false,
deprecationControl: DeprecationControl = DeprecationControl(),
warningLevel: CompilerWarningLevel = .all,
importers: [ImportResolver] = [],
functions: SassFunctionMap = [:]) {
precondition(embeddedCompilerFileURL.isFileURL, "Not a file URL: \(embeddedCompilerFileURL)")
self.eventLoopGroup = eventLoopGroup
self.embeddedCompilerFileURL = embeddedCompilerFileURL
self.embeddedCompilerFileArgs = embeddedCompilerFileArguments
state = .initializing
startCount = 0
settings = Settings(timeout: timeout,
globalImporters: importers,
globalFunctions: functions,
messageStyle: messageStyle,
verboseDeprecations: verboseDeprecations,
deprecationControl: deprecationControl,
warningLevel: warningLevel)
stateWaitingQueue = ContinuationQueue()
activeRequests = [:]
quiesceContinuation = nil
runTask = nil
Task { // what the fuck is up with this...
await self.initThunk()
}
}
private func initThunk() async {
await TestSuspend?.suspend(for: .initThunk)
runTask = Task { await run() }
}
deinit {
// XXX need isolated-deinit or something
// precondition(activeRequests.isEmpty)
precondition(state.isShutdown, "Compiler not shutdown: \(state)")
}
/// XXX Factored-out inner loop, hope that isolation-inheritance will make this work better in Swift 6...
/// XXX should be fixed with [isolated self]
private func runInstance(child: CompilerChild) async throws {
debug("Compiler is started, starting healthcheck")
setState(.checking(child))
// Kick off the child task to deal with compiler responses
async let messageLoopTask: Void = runMessageLoop()
let versions = try await sendVersionRequest(to: child)
try versions.check()
self.versions = versions
// Might already be quiescing here, race with msgloop task
if state.isChecking {
setState(.running(child))
await waitForStateChange()
}
await messageLoopTask
precondition(state.isQuiescing, "Expected quiescing, is \(state)")
debug("Quiescing work for \(Task.isCancelled ? "shutdown" : "restart")")
await quiesce()
debug("Quiesce complete, no outstanding compilations")
}
/// Run and maintain the Sass compiler.
///
/// Cancelling this ``Task`` initiates a graceful exit of the compiler.
private func run() async {
precondition(state.isInitializing, "Unexpected state at run(): \(state)")
let initThread = NIOSingletons.posixBlockingThreadPool
let eventLoop = eventLoopGroup.any()
while !Task.isCancelled {
do {
setState(.initializing)
precondition(!hasActiveRequests)
startCount += 1
// Get onto the thread to start the child process
let child = try await initThread.runIfActive(eventLoop: eventLoop) {
try CompilerChild(fileURL: self.embeddedCompilerFileURL,
arguments: self.embeddedCompilerFileArgs,
workHandler: { [unowned self] in try await receive(message: $0, reply: $1) },
errorHandler: { [unowned self] in await handleError($0) })
}.get()
try await child.run(group: eventLoop) {
await TestSuspend?.suspend(for: .endOfInitializing)
try await runInstance(child: child)
}
} catch is CancellationError {
// means we got cancelled waiting for the version query - go straight to shutdown.
} catch {
setState(.broken(error))
debug("Can't start the compiler at all: \(error)")
await stopAndCancelWork(with: error)
while state.isBroken {
await waitForStateChange()
}
}
}
setState(.shutdown)
debug("Compiler is shutdown")
}
/// Deal with inbound messages.
///
/// This runs as a structured child task of `runTask` with cancellation propagation.
private func runMessageLoop() async {
let child = state.child!
await child.processMessages()
debug("Compiler message-loop ended, cancelled = \(Task.isCancelled)")
setState(.quiescing(child))
if Task.isCancelled {
await stopAndCancelWork(with: CancellationError())
}
}
/// Restart the embedded Sass compiler.
///
/// Normally a single instance of the compiler's process persists across all invocations to
/// `compile(...)` on this `Compiler` instance. This method stops the current
/// compiler process and starts a new one: the intended use is for compilers whose
/// resource usage escalates over time and need calming down. You probably don't need to
/// call it.
///
/// Any outstanding compilations are failed.
///
/// Throws an error if the compiler cannot be restarted due to error or because `shutdownGracefully()`
/// has already been called.
public func reinit() async throws {
// Figure out if we need to prompt a reset
if state.isRunning || state.isBroken {
await handleError(LifecycleError("User requested Sass compiler be reinitialized"))
while !state.isInitializing {
await waitForStateChange()
}
}
// Now wait for the reset to finish
while true {
switch state {
case .initializing, .checking, .quiescing:
await waitForStateChange()
case .running:
// reset complete
return
case .broken(let error):
debug("Restart failed: \(error)")
throw error
case .shutdown:
throw LifecycleError("Attempt to reinit() compiler that is already shut down")
}
}
}
/// Shut down the compiler.
///
/// You must call this before the last reference to the `Compiler` is released.
///
/// Cancels any outstanding work and shuts down internal threads. There’s no way back
/// from this state: to do more compilation you will need a new instance.
public func shutdownGracefully() async {
while runTask == nil { // dumb window during init thunk
await waitForStateChange()
}
debug("Shutdown request from \(state), active count=\(activeRequests.count)")
runTask?.cancel()
while !state.isShutdown {
if state.isBroken {
setState(.initializing)
} else {
await waitForStateChange()
}
}
}
/// Test hook
func waitForRunning() async { await waitFor(\.isRunning) }
func waitForBroken() async { await waitFor(\.isBroken) }
func waitForQuiescing() async { await waitFor(\.isQuiescing) }
func waitFor(_ statekp: KeyPath<State, Bool>) async {
while !state[keyPath: statekp] {
await waitForStateChange()
}
}
/// The process ID of the embedded Sass compiler.
///
/// Not normally needed; could be used to adjust resource usage or maybe send it a signal if stuck.
/// The process ID is reported after waiting for any [re]initialization to complete; a value of `nil`
/// means that the compiler is broken or shutdown.
public var compilerProcessIdentifier: Int32? {
get async {
while true {
switch state {
case .broken, .shutdown:
return nil
case .checking(let child), .running(let child), .quiescing(let child):
return child.processIdentifier
case .initializing:
await waitForStateChange()
}
}
}
}
/// The name of the underlying Sass implementation. `nil` if unknown.
public var compilerName: String? {
get async {
await stableVersions?.compilerName
}
}
/// The version of the underlying Sass implementation. For Dart Sass and LibSass this is in
/// [semver](https://semver.org/spec/v2.0.0.html) format. `nil` if unknown (never got a version).
public var compilerVersion: String? {
get async {
await stableVersions?.compilerVersionString
}
}
/// The version of the package implementing the compiler side of the embedded Sass protocol.
/// Probably in [semver](https://semver.org/spec/v2.0.0.html) format.
/// `nil` if unknown (never got a version).
public var compilerPackageVersion: String? {
get async {
await stableVersions?.packageVersionString
}
}
private var stableVersions: Versions? {
get async {
while true {
switch state {
case .broken, .shutdown, .running, .quiescing:
return versions
case .checking, .initializing:
await waitForStateChange()
}
}
}
}
// MARK: Logger
nonisolated static let logger = Logger(label: "dart-sass")
func debug(_ msg: @autoclosure () -> String) {
Compiler.logger.debug(.init(stringLiteral: msg()))
}
// MARK: Compilation entrypoints
typealias Continuation<T> = CheckedContinuation<T, any Error>
/// Compile to CSS from a stylesheet file.
///
/// - parameters:
/// - fileURL: The URL of the file to compile. The file extension determines the
/// expected syntax of the contents, so it must be css/scss/sass.
/// - outputStyle: How to format the produced CSS. Default `.expanded`.
/// - sourceMapStyle: Kind of source map to create for the CSS. Default `.separateSources`.
/// - includeCharset: If the output is non-ASCII, whether to include `@charset`.
/// - importers: Rules for resolving `@import` etc. for this compilation, used in order after
/// `fileURL`'s directory and any set globally.. Default none.
/// - functions: Functions for this compilation, overriding any with the same name previously
/// set globally. Default none.
/// - throws: `CompilerError` if there is a critical error with the input, for example a syntax error.
/// Some other kind of error if something goes wrong with the compiler infrastructure itself.
/// - returns: `CompilerResults` with CSS and optional source map.
public func compile(fileURL: URL,
outputStyle: CssStyle = .expanded,
sourceMapStyle: SourceMapStyle = .separateSources,
includeCharset: Bool = false,
importers: [ImportResolver] = [],
functions: SassFunctionMap = [:]) async throws -> CompilerResults {
try await withCheckedThrowingContinuation { continuation in
Task {
let child = try await waitUntilReadyToCompile(continuation: continuation)
let msg = startCompilation(input: .path(fileURL.path),
outputStyle: outputStyle,
sourceMapStyle: sourceMapStyle,
includeCharset: includeCharset,
importers: .init(importers),
functions: functions,
continuation: continuation)
await child.send(message: msg)
}
}
}
/// Compile to CSS from an inline stylesheet.
///
/// - parameters:
/// - string: The stylesheet text to compile.
/// - syntax: The syntax of `string`, default `.scss`.
/// - url: The absolute URL to associate with `string`, from where it was loaded.
/// Default `nil` meaning unknown.
/// - importer: Rule to resolve `@import` etc. from `string` relative to `url`. Default `nil`
/// meaning no filesystem importer is configured. Unlike some Sass implementations this means that
/// imports of files from the current directory don't work automatically: add a loadpath to `importers`.
/// - outputStyle: How to format the produced CSS. Default `.expanded`.
/// - sourceMapStyle: Kind of source map to create for the CSS. Default `.separateSources`.
/// - includeCharset: If the output is non-ASCII, whether to include `@charset`.
/// - importers: Rules for resolving `@import` etc. for this compilation, used in order after
/// any set globally. Default none.
/// - functions: Functions for this compilation, overriding any with the same name previously
/// set globally. Default none.
/// - throws: `CompilerError` if there is a critical error with the input, for example a syntax error.
/// Some other kind of error if something goes wrong with the compiler infrastructure itself.
/// - returns: `CompilerResults` with CSS and optional source map.
public func compile(string: String,
syntax: Syntax = .scss,
url: URL? = nil,
importer: ImportResolver? = nil,
outputStyle: CssStyle = .expanded,
sourceMapStyle: SourceMapStyle = .separateSources,
includeCharset: Bool = false,
importers: [ImportResolver] = [],
functions: SassFunctionMap = [:]) async throws -> CompilerResults {
try await withCheckedThrowingContinuation { continuation in
Task {
let child = try await waitUntilReadyToCompile(continuation: continuation)
let msg = startCompilation(
input: .string(.with { m in
m.source = string
m.syntax = .init(syntax)
url.map {
m.url = $0.absoluteString
}
importer.map {
m.importer = .init($0, id: CompilationRequest.baseImporterID)
}
}),
outputStyle: outputStyle,
sourceMapStyle: sourceMapStyle,
includeCharset: includeCharset,
importers: importers,
stringImporter: importer,
functions: functions,
continuation: continuation)
await child.send(message: msg)
}
}
}
private func waitUntilReadyToCompile(continuation: Continuation<CompilerResults>) async throws -> CompilerChild {
while true {
let err: (any Error)?
switch state {
case .broken(let error):
// submitted while restarting the compiler; restart failed: fail
err = LifecycleError("Sass compiler failed to start after unrecoverable error: \(error)")
case .shutdown:
// submitted after/during shutdown: fail
err = LifecycleError("Sass compiler has been shut down, not accepting work")
case .initializing, .quiescing, .checking:
// submitted while [re]starting the compiler: wait
err = nil
await waitForStateChange()
case .running(let child):
// ready to go
return child
}
if let err {
continuation.resume(throwing: err)
throw err
}
}
}
// MARK: Version query
// Unit-test hook to inject/drop version request
private var versionsResponder: VersionsResponder? = nil
func setVersionsResponder(_ responder: VersionsResponder?) {
self.versionsResponder = responder
}
private func sendVersionRequest(to child: CompilerChild) async throws -> Versions {
try await withCheckedThrowingContinuation { continuation in
Task {
await TestSuspend?.suspend(for: .sendVersionRequest)
guard state.isChecking else {
debug("Cancelling versions query, state moved on to \(state)")
continuation.resume(throwing: CancellationError())
return
}
let msg = startVersionRequest(continuation: continuation)
if let versionsResponder {
if let rsp = await versionsResponder.provideVersions(msg: msg) {
await child.receive(message: rsp)
}
} else {
await child.send(message: msg)
}
}
}
}
/// Central transport/protocol error detection and 'recovery'.
///
/// Errors come from:
/// 1. Write transport errors, reported by `CompilerChild.send(...)`
/// 2. Read transport errors, reported by the `CompilerChild.processMessages(...)`
/// 3. Protocol errors reported by the Sass compiler, from `CompilerWork.receieveGlobal(message:)`
/// 4. Protocol errors detected by us, from `CompilationRequest.receive(message)`.
/// 5. User-injected restarts, from `reinit()`.
/// 6. Timeouts, from `CompilerWork`'s reset API.
///
/// In all cases we brutally restart the compiler and fail back all the jobs.
/// In the async world this is collapsing into "SACW" which is good...
func handleError(_ error: any Error) async {
switch state {
case .initializing:
// Nothing to do, if something fails we'll notice
break
case .checking:
// Timeout or something while checking the version, kick the process
// and let the init process handle the error.
debug("Error while checking compiler, stopping it")
await stopAndCancelWork(with: error)
case .running(let child):
debug("Restarting compiler from running")
setState(.quiescing(child))
await stopAndCancelWork(with: error)
case .broken:
// Reinit attempt
debug("Error (\(error)) while broken, reinit compiler")
setState(.initializing)
case .quiescing:
// Corner/race stay in this state but try to hurry things along.
debug("Error while quiescing, stopping compiler")
await stopAndCancelWork(with: error)
case .shutdown:
// Nothing to do
debug("Error (\(error)) while shutdown - doing nothing")
}
}
private func stopAndCancelWork(with error: any Error) async {
await state.child?.stop()
cancelAllActive(with: error)
}
}
/// NIO layer
///
/// Looks after the actual child process.
/// Knows how to set up the channel pipeline.
/// Routes inbound messages to CompilerWork.
actor CompilerChild: ChannelInboundHandler {
typealias InboundIn = InboundMessage
/// The child process
private let child: Exec.Child
/// Message handling (ex. the work manager)
typealias WorkHandler = @Sendable (InboundMessage, @escaping ReplyFn) async throws -> Void
private let workHandler: WorkHandler
/// Error handling
typealias ErrorHandler = @Sendable (any Error) async -> Void
private let errorHandler: ErrorHandler
/// Cancellation protocol
private var stopping: Bool
/// API
nonisolated let processIdentifier: Int32
/// Internal for test
var channel: Channel {
asyncChannel!.channel
}
/// This is such a mess...
private var asyncChannel: NIOAsyncChannel<InboundMessage, OutboundMessage>!
private var inbound: NIOAsyncChannelInboundStream<InboundMessage>!
private(set) var outbound: NIOAsyncChannelOutboundWriter<OutboundMessage>!
/// Create a new Sass compiler process.
///
/// Must not be called in an event loop! But I don't know how to check that.
init(fileURL: URL, arguments: [String], workHandler: @escaping WorkHandler, errorHandler: @escaping ErrorHandler) throws {
self.child = try Exec.spawn(fileURL, arguments)
self.processIdentifier = child.process.processIdentifier
self.workHandler = workHandler
self.errorHandler = errorHandler
self.stopping = false
// The termination handler is always called when the process ends.
// Only cascade this up into a compiler restart when we're not already
// stopping, ie. we didn't just ask the process to end.
self.child.process.terminationHandler = { _ in
Task { await self.childTerminationHandler() }
}
}
private func childTerminationHandler() async {
await TestSuspend?.suspend(for: .childTermination)
if !stopping {
// unfortunate race condition here while this & Compile are on separate actors - new work received
// before the compiler actually does this call will get smashed and failed rather than queued.
await errorHandler(ProtocolError("Compiler process exitted unexpectedly"))
}
}
/// Shutdown point - stop the child process to clean up the channel.
/// Rely on `Compiler.stopAndCancelWork()` sequencing with the active work queue.
func stop() {
if !stopping {
stopping = true
child.process.terminationHandler = nil
child.terminate()
// Linux weirdness - `terminate` doesn't cause the AsyncChannel to finish even though
// it definitely stops the process - so we have to poke it:
//
// 1) asyncChannel?.outboundWriter.finish() --- worked once then never again, maybe I imagined it
// 2) kill(processIdentifier, -9) --- no effect
// 3) horrendous multi-layered Task version of msgLoopTask enabling Swift concurrency
// cancel -- which NIO is far more interested in than the pipe going broken -- seems
// to work. Fuck me.
msgLoopTask?.cancel()
}
}
/// Connect the unix child process to NIO and run
func run(group: EventLoopGroup, callback: @Sendable () async throws -> Void ) async throws {
asyncChannel = try await NIOPipeBootstrap(group: group)
.takingOwnershipOfDescriptors(input: child.stdoutFD, output: child.stdinFD) { ch in
ProtocolWriter.addHandler(to: ch)
.flatMap {
ProtocolReader.addHandler(to: ch)
}
.flatMapThrowing {
try NIOAsyncChannel(wrappingChannelSynchronously: ch)
}
}
try await asyncChannel.executeThenClose { @Sendable inbound, outbound in
try await runThunk(inbound: inbound, outbound: outbound, callback: callback)
}
}
/// XXX Waiting for [isolated self]
private func runThunk(inbound: NIOAsyncChannelInboundStream<InboundMessage>,
outbound: NIOAsyncChannelOutboundWriter<OutboundMessage>,
callback: @Sendable () async throws -> Void) async throws {
self.inbound = inbound
self.outbound = outbound
defer {
self.inbound = nil
self.outbound = nil
}
try await callback()
}
/// Send a message to the Sass compiler with error detection.
func send(message: OutboundMessage) async {
precondition(asyncChannel != nil)
guard !stopping else {
// Race condition of compiler reset vs. async host function
return
}
do {
try await outbound.write(message) // == writeAndFlush
} catch {
// tough to reliably hit this error. if we kill the process while trying to write to
// it we get this on Darwin maybe 20% of the time vs. the write working, leaving the
// sigchld handler to clean up. The test suite forces us down here by calling the
// mysterious 'finish' method on the async writer.
await errorHandler(ProtocolError("Write to Sass compiler failed: \(error)"))
}
}
private var msgLoopTask: Task<Void, Never>?
/// Process messages from the child until it dies or the task is cancelled
///
/// See `stop()` for why this nonsense is so nonsensical.
func processMessages() async {
await withTaskCancellationHandler {
msgLoopTask = Task {
await processMessages2()
}
await msgLoopTask?.value
} onCancel: {
Task { await msgLoopTask?.cancel() }
}
}
private func processMessages2() async {
precondition(asyncChannel != nil)
do {
for try await message in inbound {
// only 'async' because of hop to Compiler actor - is non-blocking synchronous on client side
await receive(message: message)
}
} catch is CancellationError {
} catch {
// Called from NIO up the stack if our binary protocol reader has a problem
await errorHandler(ProtocolError("Read from Sass compiler failed: \(error)"))
}
}
/// Split out for test access
func receive(message: InboundMessage) async {
guard !stopping else {
// I don't really understand how this happens but have test proof on Linux
// on Github Actions env, seems to be an inbound buffer where something can
// get caught and appear even after the child process is terminated.
Compiler.logger.debug("Rx: \(message.logMessage) while stopping, discarding")
return
}
Compiler.logger.debug("Rx: \(message.logMessage)")
do {
try await workHandler(message) {
await self.send(message: $0)
}
} catch {
await errorHandler(error)
}
}
}
/// Version response injection for testing
protocol VersionsResponder: Sendable {
func provideVersions(msg: OutboundMessage) async -> InboundMessage?
}
/// Dumb enum helpers
extension Compiler.State {
var isBroken: Bool {
if case .broken = self {
return true
}
return false
}
var isChecking: Bool {
if case .checking = self {
return true
}
return false
}
var isInitializing: Bool {
if case .initializing = self {
return true
}
return false
}
var isQuiescing: Bool {
if case .quiescing = self {
return true
}
return false
}
var isRunning: Bool {
if case .running = self {
return true
}
return false
}
var isShutdown: Bool {
if case .shutdown = self {
return true
}
return false
}
}