Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ board can be cross-checked against what actually shipped.

## [Unreleased]

### Fixed

- A call whose answer has no body stayed pending, with a duration that kept growing, when the app never read the answer, as it does not after looking at the status of a conditional request. The call now ends when the head arrives, for the answer to a HEAD request, a 204, a 304 and an answer that says it is empty. A redirect still waits, because the client may follow it
- The recording dot, and the status and the time of a call that is still running, pulsed for as long as they were on screen, which kept the app drawing a frame on every refresh with nothing else changing. A pulse now beats three times when it appears, the recording dot beats again when the history changes, and then it holds still. It also no longer starts when the system asks for reduced motion, where it ran without being seen

### Internal

- The release workflow looks up the archive of each package by the version of the tag, for the attestation and for the record on the Packages panel. It used the latest version that pub.dev lists, which can still be the previous one for minutes after an upload, and on 1.0.0 it was for two of the four packages when the record job read it
Expand Down
29 changes: 28 additions & 1 deletion packages/flutwire/lib/src/capture/recorder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ final class FlutwireCallRecorder {

/// Reports the response head. Calling it a second time, as a client does
/// after following a redirect, replaces the first.
///
/// A response that has no body ends the call here: the answer to a HEAD
/// request, a 204, a 304 and one that says it is empty. A client reads such a
/// body only when it wants to, and most never do, so waiting for the stream
/// to end would leave the call running for as long as the app does. A
/// redirect is left open, because the client may follow it and report the
/// head of what it finds.
void responseHead({
required int statusCode,
required FlutwireHeaders headers,
Expand Down Expand Up @@ -137,7 +144,11 @@ final class FlutwireCallRecorder {
compression: compression,
),
);
_publish(finished: false);
if (_endsAtHead(statusCode, headers)) {
_end(FlutwireCallState.completed);
} else {
_publish(finished: false);
}
});

/// Adds a chunk of the response body.
Expand Down Expand Up @@ -284,6 +295,22 @@ final class FlutwireCallRecorder {

static const Duration _progressInterval = Duration(milliseconds: 200);

static const Set<int> _redirectStatuses = {301, 302, 303, 307, 308};

/// Whether nothing can follow the head of this response, so that the call is
/// over with it. RFC 9110, section 6.4.1, says which responses have no body.
bool _endsAtHead(int statusCode, FlutwireHeaders headers) {
// After a protocol switch come the bytes of the new protocol, and a client
// may follow a redirect and report the head of what it finds.
if (statusCode < 200 || _redirectStatuses.contains(statusCode)) {
return false;
}
return _call.method == 'HEAD' ||
statusCode == 204 ||
statusCode == 304 ||
int.tryParse(headers.first('content-length')?.trim() ?? '') == 0;
}

/// The address of a WebSocket as the app wrote it. The handshake is an HTTP
/// request, so the capture is handed an `http` or `https` address, with a
/// port of 0 when the app gave none and an empty fragment.
Expand Down
1 change: 1 addition & 0 deletions packages/flutwire/lib/src/ui/inspector/recording_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class RecordingRow extends StatelessWidget {
children: [
Pulse(
active: !paused,
restartOn: session.store.calls,
child: Container(
width: 8,
height: 8,
Expand Down
107 changes: 87 additions & 20 deletions packages/flutwire/lib/src/ui/widgets/pulse.dart
Original file line number Diff line number Diff line change
@@ -1,61 +1,128 @@
import 'package:flutter/material.dart';

/// Fades its child in and out, slowly, to say that something is in progress.
/// It holds still when the system asks for reduced motion.
/// Fades its child out and in a few times, to say that something is going on,
/// and then holds still.
///
/// It beats when it first appears active, when it becomes active, and when
/// [restartOn] notifies after the beats are over. It never goes on until told
/// to stop: an animation that runs for as long as the inspector is open keeps
/// the app drawing a frame on every refresh for nothing. It holds still as well
/// when the system asks for reduced motion.
class Pulse extends StatefulWidget {
/// Pulses [child].
const Pulse({super.key, required this.child, this.active = true});
const Pulse({
super.key,
required this.child,
this.active = true,
this.restartOn,
});

/// What pulses.
final Widget child;

/// Whether it pulses at all.
final bool active;

/// Makes it beat again each time it notifies, as new traffic does.
final Listenable? restartOn;

@override
State<Pulse> createState() => _PulseState();
}

class _PulseState extends State<Pulse> with SingleTickerProviderStateMixin {
static const int _beats = 3;
static const Duration _beat = Duration(milliseconds: 2200);

/// Out to a dim and back in, once for each beat, ending as it began.
static final Animatable<double> _fade = TweenSequence<double>([
for (var i = 0; i < _beats; i++) ...[
_half(from: 1, to: 0.4),
_half(from: 0.4, to: 1),
],
]);

static TweenSequenceItem<double> _half({
required double from,
required double to,
}) => TweenSequenceItem(
tween: Tween<double>(
begin: from,
end: to,
).chain(CurveTween(curve: Curves.easeInOut)),
weight: 1,
);

late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1100),
duration: _beat * _beats,
);
late final Animation<double> _opacity = _controller.drive(_fade);

bool _reduced = false;

/// Whether the beats for the current activation have been started.
bool _started = false;

@override
void initState() {
super.initState();
if (widget.active) _controller.repeat(reverse: true);
widget.restartOn?.addListener(_restart);
}

@override
void didChangeDependencies() {
super.didChangeDependencies();
_reduced = MediaQuery.disableAnimationsOf(context);
if (_reduced) {
_hold();
} else if (widget.active && !_started) {
_begin();
}
}

@override
void didUpdateWidget(Pulse old) {
super.didUpdateWidget(old);
if (widget.active && !_controller.isAnimating) {
_controller.repeat(reverse: true);
} else if (!widget.active && _controller.isAnimating) {
_controller.stop();
_controller.value = 0;
if (old.restartOn != widget.restartOn) {
old.restartOn?.removeListener(_restart);
widget.restartOn?.addListener(_restart);
}
if (!widget.active) {
if (old.active) _hold();
} else if (!_started && !_reduced) {
_begin();
}
}

@override
void dispose() {
widget.restartOn?.removeListener(_restart);
_controller.dispose();
super.dispose();
}

void _begin() {
_started = true;
_controller.forward(from: 0);
}

void _hold() {
_started = false;
_controller.stop();
_controller.value = 0;
}

/// Notifications come in bursts, and one call tells of itself several times
/// as it goes. Those that arrive while the beats play do not add to them.
void _restart() {
if (!widget.active || _reduced || _controller.isAnimating) return;
_begin();
}

@override
Widget build(BuildContext context) {
if (!widget.active || MediaQuery.disableAnimationsOf(context)) {
return widget.child;
}
return FadeTransition(
opacity: Tween<double>(
begin: 1,
end: 0.4,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)),
child: widget.child,
);
if (!widget.active || _reduced) return widget.child;
return FadeTransition(opacity: _opacity, child: widget.child);
}
}
112 changes: 112 additions & 0 deletions packages/flutwire/test/capture/recorder_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,118 @@ void main() {
});
});

// A client reads the body of such a response only when it feels like it, and
// for most of them it never does. Waiting for a reader would leave the call
// running for as long as the app does.
group('a response that has no body', () {
FlutwireCall answer(
int status, {
String method = 'GET',
Map<String, String> headers = const {},
}) {
final t = make();
final rec = t.engine.begin(
method: method,
uri: Uri.parse('https://x.test/'),
source: FlutwireSource.io,
)!;
rec.responseHead(
statusCode: status,
headers: FlutwireHeaders.fromMap(headers),
);
return t.store.find(rec.id)!;
}

test('ends the call at its head when it is a 304 or a 204', () {
for (final status in [204, 304]) {
final call = answer(status);
expect(call.state, FlutwireCallState.completed, reason: '$status');
expect(call.statusCode, status);
expect(call.endedAt, isNotNull);
expect(call.response!.body.totalBytes, 0);
}
});

test('ends the call at its head when it answers a HEAD request', () {
expect(answer(200, method: 'HEAD').state, FlutwireCallState.completed);
});

test('ends the call at its head when it says it is empty', () {
final call = answer(201, headers: {'Content-Length': ' 0 '});
expect(call.state, FlutwireCallState.completed);
});

test('leaves the call open when a body may follow', () {
expect(answer(200).state, FlutwireCallState.pending);
expect(
answer(200, headers: {'content-length': '12'}).state,
FlutwireCallState.pending,
);
// The bytes of the protocol it switches to follow a 101, whatever else
// it says about its length.
expect(
answer(101, headers: {'upgrade': 'websocket'}).state,
FlutwireCallState.pending,
);
expect(
answer(
101,
headers: {'upgrade': 'websocket', 'content-length': '0'},
).state,
FlutwireCallState.pending,
);
});

test('leaves a redirect open, because the client may follow it', () {
expect(answer(301, method: 'HEAD').state, FlutwireCallState.pending);

final t = make();
final rec = t.engine.begin(
method: 'GET',
uri: Uri.parse('https://x.test/'),
source: FlutwireSource.io,
)!;
rec.responseHead(
statusCode: 302,
headers: FlutwireHeaders.fromMap({
'content-length': '0',
'location': '/next',
}),
);
expect(t.store.find(rec.id)!.state, FlutwireCallState.pending);

rec
..responseHead(
statusCode: 200,
headers: FlutwireHeaders.fromMap({'content-type': 'text/plain'}),
)
..responseBody(utf('hello'))
..complete();
final call = t.store.find(rec.id)!;
expect(call.statusCode, 200);
expect(call.response!.body.text, 'hello');
});

test('ignores the reports that come after the end', () {
final t = make();
final rec = t.engine.begin(
method: 'GET',
uri: Uri.parse('https://x.test/'),
source: FlutwireSource.io,
)!;
rec.responseHead(statusCode: 304, headers: FlutwireHeaders.empty);
final ended = t.store.find(rec.id)!.endedAt;
t.clock.advance(const Duration(seconds: 5));
rec
..responseBody(utf('late'))
..cancel();
final call = t.store.find(rec.id)!;
expect(call.state, FlutwireCallState.completed);
expect(call.endedAt, ended);
expect(call.response!.body.totalBytes, 0);
});
});

test('a response is hidden by the redaction policy, body included', () {
final t = make(
const FlutwireConfig(
Expand Down
Loading
Loading