작은 WS frame을 isolate 왕복 없이 처리해 full 성능 검증에서 확인된 Dart WS burst hard-gate 회귀를 닫는다. 성능 검증 루프의 task archive와 로드맵 완료 근거도 함께 보존한다.
253 lines
8.8 KiB
Dart
253 lines
8.8 KiB
Dart
import 'dart:async';
|
|
import 'dart:isolate';
|
|
import 'dart:typed_data';
|
|
|
|
import 'inbound_gateway.dart';
|
|
import 'packets/message_common.pb.dart';
|
|
|
|
/// Long-running isolate worker gateway for Dart IO targets.
|
|
///
|
|
/// A single isolate is spawned once via [start] and kept alive to avoid
|
|
/// repeating spawn cost per frame. Raw frames are sent over a [SendPort] with
|
|
/// their [InboundFrame.seq]; the isolate decodes the [PacketBase] envelope
|
|
/// (pure work only) and sends the fields back. Results are reordered by [seq]
|
|
/// and emitted on [results] for the main-isolate coordinator to dispatch.
|
|
///
|
|
/// The gateway deliberately owns no stateful dispatch: listeners, request
|
|
/// handlers, the pending-request map, and the outbound write queue all remain
|
|
/// on the main isolate's receive coordinator.
|
|
class IsolateInboundGateway implements InboundGateway {
|
|
/// Max frames handed to the worker without a result back before [submit]
|
|
/// applies backpressure. Bounds the otherwise unbounded [SendPort] mailbox.
|
|
static const int defaultCapacity = 1024;
|
|
|
|
/// Threshold in bytes to use [TransferableTypedData] instead of copying.
|
|
static const int transferableThreshold = 32768;
|
|
|
|
/// Frames smaller than this decode in-process on the main isolate instead of
|
|
/// being handed to the worker. A per-frame isolate round-trip costs far more
|
|
/// than decoding a small envelope inline, so routing a high-rate burst of
|
|
/// small frames through the worker collapses receive throughput (the WS burst
|
|
/// regression). Large frames still offload to keep big-payload decode off the
|
|
/// main isolate. Aligned with [transferableThreshold] so a single size
|
|
/// boundary governs both isolate offload and zero-copy transfer. In-process
|
|
/// results still flow through the shared seq-keyed [FrameReorderBuffer], so
|
|
/// global dispatch order is preserved across both paths.
|
|
static const int inProcessDecodeThreshold = transferableThreshold;
|
|
|
|
final int capacity;
|
|
|
|
final StreamController<DecodedFrame> _controller =
|
|
StreamController<DecodedFrame>.broadcast();
|
|
final FrameReorderBuffer _reorder = FrameReorderBuffer();
|
|
|
|
Isolate? _isolate;
|
|
SendPort? _toWorker;
|
|
ReceivePort? _fromWorker;
|
|
Completer<void>? _ready;
|
|
bool _closed = false;
|
|
|
|
int _inFlight = 0;
|
|
Completer<void>? _capacityWaiter;
|
|
|
|
IsolateInboundGateway({this.capacity = defaultCapacity})
|
|
: assert(capacity > 0, 'capacity must be positive');
|
|
|
|
@override
|
|
Stream<DecodedFrame> get results => _controller.stream;
|
|
|
|
@override
|
|
int get inFlightCount => _inFlight;
|
|
|
|
@override
|
|
int get queuedCount => _reorder.pendingCount;
|
|
|
|
@override
|
|
Future<void> start() async {
|
|
if (_isolate != null || _closed) return;
|
|
final ready = Completer<void>();
|
|
_ready = ready;
|
|
final fromWorker = ReceivePort();
|
|
_fromWorker = fromWorker;
|
|
fromWorker.listen(_onWorkerMessage);
|
|
_isolate = await Isolate.spawn(_gatewayEntry, fromWorker.sendPort);
|
|
await ready.future;
|
|
}
|
|
|
|
void _onWorkerMessage(dynamic message) {
|
|
if (message is SendPort) {
|
|
_toWorker = message;
|
|
if (_ready != null && !_ready!.isCompleted) _ready!.complete();
|
|
_ready = null;
|
|
return;
|
|
}
|
|
if (message is List) {
|
|
_inFlight--;
|
|
_releaseCapacity();
|
|
final error = message[5];
|
|
final dynamic rawData = message[2];
|
|
final List<int> data;
|
|
if (rawData is TransferableTypedData) {
|
|
data = rawData.materialize().asUint8List();
|
|
} else {
|
|
data = (rawData as List).cast<int>();
|
|
}
|
|
final decoded = error != null
|
|
? DecodedFrame.error(seq: message[0] as int, error: error)
|
|
: DecodedFrame(
|
|
seq: message[0] as int,
|
|
typeName: message[1] as String,
|
|
data: data,
|
|
incomingNonce: message[3] as int,
|
|
responseNonce: message[4] as int,
|
|
);
|
|
_emitReleased(decoded);
|
|
}
|
|
}
|
|
|
|
/// Pushes a decoded frame through the shared reorder buffer and emits whatever
|
|
/// contiguous run is now releasable, in seq order. Used by both the worker
|
|
/// reply path and the in-process small-frame path so ordering is identical.
|
|
void _emitReleased(DecodedFrame decoded) {
|
|
for (final ready in _reorder.release(decoded)) {
|
|
if (!_controller.isClosed) _controller.add(ready);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> submit(InboundFrame frame) async {
|
|
if (_closed) return;
|
|
// Small frames decode in-process: the isolate round-trip costs more than
|
|
// decoding a small envelope inline, and a high-rate small-frame burst routed
|
|
// through the worker collapses throughput. The result still flows through the
|
|
// shared seq-keyed reorder buffer, so it stays ordered with isolate-decoded
|
|
// large frames (a small seq buffers until any earlier large seq releases).
|
|
if (frame.bytes.length < inProcessDecodeThreshold) {
|
|
_emitReleased(_decodeInProcess(frame));
|
|
return;
|
|
}
|
|
// Wait for the worker's SendPort so an early frame is never dropped.
|
|
final ready = _ready;
|
|
if (ready != null) await ready.future;
|
|
// Backpressure: hold until an in-flight result frees a slot.
|
|
while (!_closed && _inFlight >= capacity) {
|
|
final waiter = _capacityWaiter ??= Completer<void>();
|
|
await waiter.future;
|
|
}
|
|
if (_closed) return;
|
|
final port = _toWorker;
|
|
if (port == null) return;
|
|
_inFlight++;
|
|
final bytes = frame.bytes;
|
|
final Object payload;
|
|
if (bytes is Uint8List) {
|
|
payload = bytes.length >= transferableThreshold
|
|
? TransferableTypedData.fromList([bytes])
|
|
: bytes;
|
|
} else {
|
|
payload = bytes.length >= transferableThreshold
|
|
? TransferableTypedData.fromList([Uint8List.fromList(bytes)])
|
|
: bytes;
|
|
}
|
|
port.send([frame.seq, payload]);
|
|
}
|
|
|
|
/// Decodes a small frame's [PacketBase] envelope on the main isolate. A
|
|
/// malformed envelope becomes an ordered [DecodedFrame.error] occupying its
|
|
/// seq, exactly as the worker path does, so the reorder buffer never stalls.
|
|
DecodedFrame _decodeInProcess(InboundFrame frame) {
|
|
try {
|
|
final common = PacketBase.fromBuffer(frame.bytes);
|
|
return DecodedFrame(
|
|
seq: frame.seq,
|
|
typeName: common.typeName,
|
|
data: common.data,
|
|
incomingNonce: common.nonce,
|
|
responseNonce: common.responseNonce,
|
|
);
|
|
} catch (error) {
|
|
return DecodedFrame.error(seq: frame.seq, error: error);
|
|
}
|
|
}
|
|
|
|
void _releaseCapacity() {
|
|
final waiter = _capacityWaiter;
|
|
if (waiter != null && _inFlight < capacity) {
|
|
_capacityWaiter = null;
|
|
waiter.complete();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> close() async {
|
|
if (_closed) return;
|
|
_closed = true;
|
|
_toWorker = null;
|
|
_isolate?.kill(priority: Isolate.immediate);
|
|
_isolate = null;
|
|
_fromWorker?.close();
|
|
_fromWorker = null;
|
|
_inFlight = 0;
|
|
_reorder.clear();
|
|
// Unblock any submit awaiting readiness or capacity so it observes _closed.
|
|
if (_ready != null && !_ready!.isCompleted) _ready!.complete();
|
|
_ready = null;
|
|
final waiter = _capacityWaiter;
|
|
_capacityWaiter = null;
|
|
waiter?.complete();
|
|
await _controller.close();
|
|
}
|
|
}
|
|
|
|
/// Isolate entry point: decodes [PacketBase] envelopes and returns fields.
|
|
///
|
|
/// Runs only pure decode work. The first message back is the worker's
|
|
/// [SendPort]; subsequent messages are `[seq, bytes]` decode requests.
|
|
///
|
|
/// Each reply is a 6-element list `[seq, typeName, data, nonce, responseNonce,
|
|
/// error]`. A malformed frame is caught and reported as an ordered error result
|
|
/// (index 5 non-null) instead of crashing the isolate, so the reorder buffer
|
|
/// never stalls on the dropped seq.
|
|
void _gatewayEntry(SendPort toMain) {
|
|
final fromMain = ReceivePort();
|
|
toMain.send(fromMain.sendPort);
|
|
fromMain.listen((dynamic message) {
|
|
if (message == null) {
|
|
fromMain.close();
|
|
return;
|
|
}
|
|
final request = message as List;
|
|
final seq = request[0] as int;
|
|
final payload = request[1];
|
|
final List<int> bytes;
|
|
if (payload is TransferableTypedData) {
|
|
bytes = payload.materialize().asUint8List();
|
|
} else {
|
|
bytes = (payload as List).cast<int>();
|
|
}
|
|
try {
|
|
final common = PacketBase.fromBuffer(bytes);
|
|
final List<int> commonData = common.data;
|
|
final Object dataPayload;
|
|
if (commonData is Uint8List) {
|
|
dataPayload = commonData.length >= IsolateInboundGateway.transferableThreshold
|
|
? TransferableTypedData.fromList([commonData])
|
|
: commonData;
|
|
} else {
|
|
dataPayload = commonData.length >= IsolateInboundGateway.transferableThreshold
|
|
? TransferableTypedData.fromList([Uint8List.fromList(commonData)])
|
|
: commonData;
|
|
}
|
|
toMain.send([
|
|
seq,
|
|
common.typeName,
|
|
dataPayload,
|
|
common.nonce,
|
|
common.responseNonce,
|
|
null,
|
|
]);
|
|
} catch (error) {
|
|
toMain.send([seq, '', const <int>[], 0, 0, 'decode failed: $error']);
|
|
}
|
|
});
|
|
}
|