Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix a race condition with re-used compilation isolate IDs #2018

Merged
merged 4 commits into from
Jun 21, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@

* Fix a deadlock when running at high concurrency on 32-bit systems.

* Fix a race condition where the embedded compiler could deadlock or crash if a
compilation ID was reused immediately after the compilation completed.

## 1.63.4

### JavaScript API
Expand Down
15 changes: 11 additions & 4 deletions lib/src/embedded/dispatcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -322,10 +322,17 @@ class Dispatcher {
var protobufWriter = CodedBufferWriter();
message.writeToCodedBufferWriter(protobufWriter);

var packet =
Uint8List(_compilationIdVarint.length + protobufWriter.lengthInBytes);
packet.setAll(0, _compilationIdVarint);
protobufWriter.writeTo(packet, _compilationIdVarint.length);
// Add one additional byte to the beginning to indicate whether or not the
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels error-prone to push a byte into the bytearray as a completion signal for the Isolate because then the Isolate has to remember about it and remove it before sending it to the host.

Would it make sense to wrap the bytearray with another object like a custom class or a tuple to make this signal more obvious? This would require changing the IsolateDispatcher's input channel to IsolateChannel<MyWrapper>, but the output channel would remain the same 🤔

Copy link
Contributor

@ntkme ntkme Jun 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dart 3 Record would be a good fit for this. E.g. IsolateChannel<(int, Uint8List)>. On the main isolate side, you can use this to get rid of the need for sending a different "initial message" in order to send the compilationId, as you can leverage this extra int.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered a record-based approach, but I decided against it for a couple reasons. First, I don't like the asymmetry between sending and receiving, especially given that the StreamChannel API requires symmetrical types. Passing an int that doubles as the compilation ID is a clever way around that, but ultimately moving the compilation ID out of the initial message doesn't really buy us anything—we've already got a pretty good way of providing the ID to the dispatcher isolate that doesn't require it to handle cases where it unexpectedly changes over time.

There's also the question of efficiency. I haven't measured, but my assumption is that (even as a value type) instantiating and passing records is going to be somewhat more overhead than expanding the binary buffer that already exists.

Ultimately, I'm not too worried about requiring the isolate dispatcher to narrow the buffer. Two classes that have a tightly coupled protocol is a relatively common pattern in message-passing scenarios like this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the asymmetry concern, and with being OK with the isolate being tightly coupled (my experience working with isolates is pretty limited, but that sounds about right).

I wouldn't think the overhead would be that big, but I also didn't think appending values to a List<T> was expensive, and it was. 😝

While I still think the pattern can be error prone, we do have tests which I think would catch a case where we forget to trim off the first byte.. I guess this is fine

// compilation is finished, so the [IsolateDispatcher] knows whether to
// treat this isolate as inactive.
var packet = Uint8List(
1 + _compilationIdVarint.length + protobufWriter.lengthInBytes);
packet[0] =
message.whichMessage() == OutboundMessage_Message.compileResponse
? 1
: 0;
packet.setAll(1, _compilationIdVarint);
protobufWriter.writeTo(packet, 1 + _compilationIdVarint.length);
_channel.sink.add(packet);
}
}
31 changes: 19 additions & 12 deletions lib/src/embedded/isolate_dispatcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -158,19 +158,27 @@ class IsolateDispatcher {
var receivePort = ReceivePort();
isolate.sink.add((receivePort.sendPort, compilationId));

var channel = IsolateChannel<Uint8List?>.connectReceive(receivePort)
.transform(const ExplicitCloseTransformer());
channel.stream.listen(_channel.sink.add,
var channel = IsolateChannel<Uint8List>.connectReceive(receivePort);
channel.stream.listen(
(message) {
// The first byte of messages from isolates indicates whether the
// entire compilation is finished. Sending this as part of the message
// buffer rather than a separate message avoids a race condition where
// the host might send a new compilation request with the same ID as
// one that just finished before the [IsolateDispatcher] receives word
// that the isolate with that ID is done. See sass/dart-sass#2004.
_channel.sink.add(Uint8List.sublistView(message, 1));
nex3 marked this conversation as resolved.
Show resolved Hide resolved
if (message[0] == 1) {
channel.sink.close();
_activeIsolates.remove(compilationId);
_inactiveIsolates.add(isolate);
resource.release();
}
},
onError: (Object error, StackTrace stackTrace) =>
_handleError(error, stackTrace),
onDone: () {
_activeIsolates.remove(compilationId);
if (_closed) {
isolate.sink.close();
} else {
_inactiveIsolates.add(isolate);
}
resource.release();
if (_closed) isolate.sink.close();
});
_activeIsolates[compilationId] = channel.sink;
return channel.sink;
Expand Down Expand Up @@ -228,8 +236,7 @@ void _isolateMain(SendPort sendPort) {
channel.stream.listen((initialMessage) async {
var (compilationSendPort, compilationId) = initialMessage;
var compilationChannel =
IsolateChannel<Uint8List?>.connectSend(compilationSendPort)
.transform(const ExplicitCloseTransformer());
IsolateChannel<Uint8List>.connectSend(compilationSendPort);
var success = await Dispatcher(compilationChannel, compilationId).listen();
if (!success) channel.sink.close();
});
Expand Down
4 changes: 4 additions & 0 deletions pkg/sass_api/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 7.1.5

* No user-visible changes.

## 7.1.4

* No user-visible changes.
Expand Down
4 changes: 2 additions & 2 deletions pkg/sass_api/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ name: sass_api
# Note: Every time we add a new Sass AST node, we need to bump the *major*
# version because it's a breaking change for anyone who's implementing the
# visitor interface(s).
version: 7.1.4
version: 7.1.5
description: Additional APIs for Dart Sass.
homepage: https://github.com/sass/dart-sass

environment:
sdk: ">=3.0.0 <4.0.0"

dependencies:
sass: 1.63.4
sass: 1.63.5

dev_dependencies:
dartdoc: ^5.0.0
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: sass
version: 1.63.5-dev
version: 1.63.5
description: A Sass implementation in Dart.
homepage: https://github.com/sass/dart-sass

Expand Down
10 changes: 10 additions & 0 deletions test/embedded/protocol_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,16 @@ void main() {
await process.shouldExit(0);
});

// Regression test for sass/dart-sass#2004
test("handles many sequential compilation requests", () async {
var totalRequests = 1000;
for (var i = 1; i <= totalRequests; i++) {
process.send(compileString("a {b: 1px + 2px}"));
await expectSuccess(process, "a { b: 3px; }");
}
await process.close();
});

test("closes gracefully with many in-flight compilations", () async {
// This should always be equal to the size of
// [IsolateDispatcher._isolatePool], since that's as many concurrent
Expand Down