Restructure step 0 done: notice table test, flaky-link proxy, liveness on the engine clock, STARSYNC_PROFILE

- notice_table_test: one row per AppEvent variant through noticeFor.
- FlakyProxy (core/testing.dart): pass, blackhole (half-open), delay,
  hold, cut after N bytes, reset; demo tests against today's server.
- Liveness (stale/drop/ping) on the engine clock, so grace can be tested
  with a fake clock; Connection takes an injectable clock.
- AppDirs with STARSYNC_PROFILE=<name>: separate data and logs, so two
  instances run on one machine.
- DEVELOPMENT: scenario list per restructure step; test tags follow the
  new step order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 20:05:52 +03:00
1 parent eac45746ce
commit f4ca02c3cf
23 files changed
+959 -48

No files matched your search

+4
View File
@@ -44,6 +44,10 @@ abstract class RelayConstants {
static const int stateHeartbeatSecs = 1;
static const int pingIntervalSecs = 5;
/// Server liveness defaults ([Liveness]): a member silent this long is
/// stale, then removed.
static const int staleAfterSecs = 15;
static const int dropAfterSecs = 30;
// Base64-char count of the avatar string, enforced at hello and
// profile. 256 KiB leaves wide headroom over any 128x128 pipeline PNG
+23 -5
View File
@@ -9,6 +9,25 @@ import 'queue.dart';
typedef SyncClock = DateTime Function();
/// How long the server bears a silent member. [staleAfter] and [dropAfter]
/// are measured on the engine's clock ([SyncEngine.clock]) by its 1 s
/// sweep, so a test with a fake clock steps through them without waiting;
/// [pingInterval] is the WebSocket's own timer (real time).
class Liveness {
/// No frame for longer than this: stale (out of the set, feed `stale`).
final Duration staleAfter;
/// No frame for longer than this: the member is removed (feed `leave`)
/// and its socket closed.
final Duration dropAfter;
/// WebSocket ping on every `/sync` socket.
final Duration pingInterval;
const Liveness({
this.staleAfter = const Duration(seconds: RelayConstants.staleAfterSecs),
this.dropAfter = const Duration(seconds: RelayConstants.dropAfterSecs),
this.pingInterval = const Duration(seconds: RelayConstants.pingIntervalSecs),
});
}
enum Transport { playing, held, finished }
enum Correction { none, slowDown, seek }
@@ -141,8 +160,9 @@ class EngineFeed {
class SyncEngine {
final SyncClock clock;
final Duration staleAfter;
final Duration dropAfter;
final Liveness liveness;
Duration get staleAfter => liveness.staleAfter;
Duration get dropAfter => liveness.dropAfter;
final EngineFeed feed = EngineFeed();
final Map<String, EngineMember> members = {};
@@ -167,10 +187,8 @@ class SyncEngine {
int _seekSeq = 0;
int _seekEventId = -1;
SyncEngine({SyncClock? clock, Duration? staleAfter, Duration? dropAfter})
SyncEngine({SyncClock? clock, this.liveness = const Liveness()})
: clock = clock ?? (() => DateTime.now().toUtc()),
staleAfter = staleAfter ?? const Duration(seconds: 15),
dropAfter = dropAfter ?? const Duration(seconds: 30),
serverTime = (clock ?? (() => DateTime.now().toUtc()))();
DateTime get now => clock();
@@ -0,0 +1,275 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
/// Which way bytes go through a [FlakyLink]: [up] client to server,
/// [down] server to client.
enum Flow { up, down, both }
/// A TCP proxy for tests that need a bad link between a client and a
/// server (DEVELOPMENT.md "Tests"): every accepted connection is a
/// [FlakyLink] the test steers while it runs: pass through, blackhole (a
/// half-open link), added latency, a late chunk, a cut after N bytes, a
/// reset. It knows nothing about WebSocket or HTTP; a "frame" is one chunk
/// as it was read from the socket (on loopback that is usually one write,
/// e.g. one small WebSocket frame).
class FlakyProxy {
final String upstreamHost;
final int upstreamPort;
final ServerSocket _server;
/// Every connection so far, in accept order.
final List<FlakyLink> links = [];
/// Called with each new link before any byte goes through it, e.g. to
/// blackhole or reset the next connection.
void Function(FlakyLink link)? onConnect;
final _accepted = StreamController<FlakyLink>.broadcast(sync: true);
FlakyProxy._(this._server, this.upstreamHost, this.upstreamPort, this.onConnect) {
_server.listen(_accept);
}
/// Listens on a free loopback port and forwards to
/// [upstreamHost]:[upstreamPort].
static Future<FlakyProxy> start(int upstreamPort,
{String upstreamHost = '127.0.0.1', void Function(FlakyLink link)? onConnect}) async {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
return FlakyProxy._(server, upstreamHost, upstreamPort, onConnect);
}
/// The port clients dial instead of the server's.
int get port => _server.port;
/// Each new link as it is accepted.
Stream<FlakyLink> get connections => _accepted.stream;
/// The latest link, null before the first.
FlakyLink? get last => links.isEmpty ? null : links.last;
Future<void> _accept(Socket client) async {
final link = FlakyLink._(client);
links.add(link);
onConnect?.call(link);
_accepted.add(link);
if (link.isClosed) return;
try {
final upstream = await Socket.connect(upstreamHost, upstreamPort);
link._attach(upstream);
} catch (_) {
link._close(rst: true);
}
}
/// Stops accepting and closes every link.
Future<void> close() async {
await _server.close();
for (final l in links) {
l._close(rst: false);
}
await _accepted.close();
}
}
/// One proxied connection. Modes change at once and can be combined
/// ([delay] with [holdNext], say); [pass] clears them all.
class FlakyLink {
final Socket _client;
Socket? _upstream;
late final _Pipe _up = _Pipe(this, Flow.up);
late final _Pipe _down = _Pipe(this, Flow.down);
final _ready = Completer<void>();
final _closed = Completer<void>();
bool _blackholed = false;
/// Latency added to every chunk, each way (on top of the link's own; a
/// chunk never overtakes an earlier one).
Duration delay = Duration.zero;
FlakyLink._(this._client) {
_quiet(_client);
_client.listen(_up._onData, onDone: _up._onDone, onError: (_) => _close(rst: false));
}
void _attach(Socket upstream) {
_upstream = upstream;
_quiet(upstream);
if (isClosed) {
upstream.destroy();
return;
}
upstream.listen(_down._onData, onDone: _down._onDone, onError: (_) => _close(rst: false));
_ready.complete();
}
static void _quiet(Socket s) {
try {
s.setOption(SocketOption.tcpNoDelay, true);
} catch (_) {}
s.done.then((_) {}, onError: (_) {});
}
/// Bytes delivered client to server / server to client.
int get bytesUp => _up.forwarded;
int get bytesDown => _down.forwarded;
/// Bytes swallowed by [blackhole] (and by a cut), both ways.
int get dropped => _up.dropped + _down.dropped;
bool get isClosed => _closed.isCompleted;
/// Completes when the link is gone (both ends closed, a reset, a cut).
Future<void> get closed => _closed.future;
/// Plain pass-through again: no blackhole, delay, held chunk or cut.
/// Bytes swallowed meanwhile stay lost (a WebSocket on it is then
/// usually broken).
void pass() {
_blackholed = false;
delay = Duration.zero;
for (final p in [_up, _down]) {
p._holdNext = null;
p._cutLeft = null;
}
}
/// Keeps both sockets open and drops every byte both ways, a closing
/// end included: neither side learns anything (a half-open link, like a
/// laptop lid closed or a NAT forgetting the flow). Chunks already
/// delayed are dropped too.
void blackhole() => _blackholed = true;
bool get blackholed => _blackholed;
/// The next chunk going [flow] (and, as TCP keeps order, everything
/// behind it) arrives [late] later than it would.
void holdNext(Duration late, {Flow flow = Flow.down}) {
for (final p in _pipes(flow)) {
p._holdNext = late;
}
}
/// Delivers [bytes] more going [flow] (counted per direction; with
/// [Flow.both] whichever gets there first), then resets the link.
void cutAfter(int bytes, {Flow flow = Flow.both}) {
for (final p in _pipes(flow)) {
p._cutLeft = bytes;
}
if (bytes <= 0) reset();
}
/// Aborts both ends at once: an RST where the platform lets us ask for
/// one (SO_LINGER 0), otherwise an abrupt close. Nothing queued is sent.
void reset() => _close(rst: true);
List<_Pipe> _pipes(Flow flow) => switch (flow) {
Flow.up => [_up],
Flow.down => [_down],
Flow.both => [_up, _down],
};
void _close({required bool rst}) {
if (isClosed) return;
_closed.complete();
for (final s in [_client, _upstream]) {
if (s == null) continue;
if (rst) _lingerZero(s);
s.destroy();
}
}
static void _lingerZero(Socket s) {
try {
// struct linger { int l_onoff = 1; int l_linger = 0; }
final v = ByteData(8)
..setInt32(0, 1, Endian.host)
..setInt32(4, 0, Endian.host);
final soLinger = Platform.isLinux || Platform.isAndroid ? 13 : 0x80;
s.setRawOption(RawSocketOption(RawSocketOption.levelSocket, soLinger, v.buffer.asUint8List()));
} catch (_) {}
}
void _bothDone() {
if (_up._srcDone && _down._srcDone) _close(rst: false);
}
}
class _Pipe {
final FlakyLink link;
final Flow flow;
_Pipe(this.link, this.flow);
int forwarded = 0;
int dropped = 0;
int? _cutLeft;
Duration? _holdNext;
bool _srcDone = false;
Future<void>? _chainTail;
Socket? get _dst => flow == Flow.up ? link._upstream : link._client;
Future<void> get _chain => _chainTail ??= link._ready.future;
void _then(Future<void> Function() step) {
_chainTail = _chain.then((_) => step()).catchError((Object _) => link._close(rst: false));
}
void _onData(Uint8List data) {
if (link.isClosed) return;
if (link._blackholed) {
dropped += data.length;
return;
}
var chunk = data;
var cut = false;
final left = _cutLeft;
if (left != null) {
if (chunk.length >= left) {
dropped += chunk.length - left;
chunk = Uint8List.sublistView(chunk, 0, left);
_cutLeft = 0;
cut = true;
} else {
_cutLeft = left - chunk.length;
}
}
var due = DateTime.now().add(link.delay);
final hold = _holdNext;
if (hold != null) {
_holdNext = null;
due = due.add(hold);
}
_then(() async {
final wait = due.difference(DateTime.now());
if (wait > Duration.zero) await Future<void>.delayed(wait);
if (link.isClosed) return;
if (link._blackholed) {
dropped += chunk.length;
return;
}
final dst = _dst!;
if (chunk.isNotEmpty) {
dst.add(chunk);
forwarded += chunk.length;
}
if (cut) {
await dst.flush();
link.reset();
}
});
}
void _onDone() {
_srcDone = true;
if (link._blackholed) {
link._bothDone();
return;
}
_then(() async {
// Half-close: the far end reads its end of stream, may still answer.
if (!link.isClosed && !link._blackholed) unawaited(_dst?.close().then((_) {}, onError: (_) {}));
link._bothDone();
});
}
}
+5
View File
@@ -0,0 +1,5 @@
/// Test support shared by core, server and desktop tests; not part of
/// `core.dart` and never imported by app or server code.
library;
export 'src/testing/flaky_proxy.dart';
+134
View File
@@ -0,0 +1,134 @@
// The flaky-link proxy itself, against a loopback echo server.
import 'dart:async';
import 'dart:io';
import 'package:sync_relay_core/testing.dart';
import 'package:test/test.dart';
/// A client socket through the proxy, collecting what comes back.
class _Client {
final Socket socket;
final received = <int>[];
final done = Completer<Object?>();
_Client(this.socket) {
socket.done.then((_) {}, onError: (_) {});
socket.listen(received.addAll,
onDone: () => done.isCompleted ? null : done.complete(null),
onError: (Object e) => done.isCompleted ? null : done.complete(e));
}
static Future<_Client> dial(FlakyProxy p) async => _Client(await Socket.connect('127.0.0.1', p.port));
void send(String s) => socket.add(s.codeUnits);
String get text => String.fromCharCodes(received);
}
Future<void> until(bool Function() ok, {Duration limit = const Duration(seconds: 3)}) async {
final end = DateTime.now().add(limit);
while (!ok()) {
if (DateTime.now().isAfter(end)) fail('condition not met in $limit');
await Future<void>.delayed(const Duration(milliseconds: 10));
}
}
void main() {
late ServerSocket echo;
late FlakyProxy proxy;
setUp(() async {
echo = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
echo.listen((s) {
s.done.then((_) {}, onError: (_) {});
s.listen(s.add, onDone: () => s.close().then((_) {}, onError: (_) {}), onError: (_) {});
});
proxy = await FlakyProxy.start(echo.port);
});
tearDown(() async {
await proxy.close();
await echo.close();
});
test('passes bytes both ways and counts them', () async {
final c = await _Client.dial(proxy);
c.send('hello');
await until(() => c.text == 'hello');
expect(proxy.links, hasLength(1));
expect(proxy.last!.bytesUp, 5);
expect(proxy.last!.bytesDown, 5);
await c.socket.close();
await proxy.last!.closed.timeout(const Duration(seconds: 3));
});
test('blackhole drops everything both ways and keeps the socket open', () async {
final c = await _Client.dial(proxy);
c.send('a');
await until(() => c.text == 'a');
proxy.last!.blackhole();
c.send('bcd');
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(c.text, 'a');
expect(proxy.last!.dropped, 3);
expect(c.done.isCompleted, isFalse);
expect(proxy.last!.isClosed, isFalse);
});
test('delay holds every chunk', () async {
final c = await _Client.dial(proxy);
await until(() => proxy.last != null);
proxy.last!.delay = const Duration(milliseconds: 150);
final sw = Stopwatch()..start();
c.send('x');
await until(() => c.text == 'x');
// Up and down each wait 150 ms.
expect(sw.elapsedMilliseconds, greaterThanOrEqualTo(290));
});
test('holdNext delivers one chunk late and keeps order', () async {
final c = await _Client.dial(proxy);
await until(() => proxy.last != null);
proxy.last!.holdNext(const Duration(milliseconds: 300), flow: Flow.up);
final sw = Stopwatch()..start();
c.send('1');
await Future<void>.delayed(const Duration(milliseconds: 50));
c.send('2');
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(c.text, '', reason: 'both still held');
await until(() => c.text == '12');
expect(sw.elapsedMilliseconds, greaterThanOrEqualTo(290));
});
test('cutAfter delivers N bytes then resets', () async {
final c = await _Client.dial(proxy);
await until(() => proxy.last != null);
proxy.last!.cutAfter(3, flow: Flow.up);
c.send('abcdef');
await c.done.future.timeout(const Duration(seconds: 3));
expect(proxy.last!.bytesUp, 3);
expect(proxy.last!.isClosed, isTrue);
});
test('reset ends the client at once', () async {
final c = await _Client.dial(proxy);
c.send('a');
await until(() => c.text == 'a');
proxy.last!.reset();
await c.done.future.timeout(const Duration(seconds: 3));
expect(proxy.last!.isClosed, isTrue);
});
test('onConnect can steer a link before any byte goes through', () async {
proxy.onConnect = (l) => l.reset();
// The RST can beat the connect's own completion.
try {
final c = await _Client.dial(proxy);
await c.done.future.timeout(const Duration(seconds: 3));
expect(c.text, '');
} on Object catch (e) {
// dart:io throws the bare OSError here (ECONNRESET), on some
// platforms a SocketException.
expect(e, anyOf(isA<OSError>(), isA<SocketException>()));
}
expect(proxy.last!.isClosed, isTrue);
});
}
+30 -1
View File
@@ -2,7 +2,7 @@
// groups "sitting out", "membership + gate", "presence gate", "stale ready
// rejoins" and the member rules of "swap" pin today's presence (out flag,
// stale sweep, leave on drop); presence In/Away/Gone with grace replaces
// them. The clock, correction, end-of-file and wire groups stay.
// them ("liveness on the engine clock" then steps through the grace). The clock, correction, end-of-file and wire groups stay.
import 'dart:convert';
import 'package:sync_relay_core/core.dart';
@@ -618,6 +618,35 @@ void main() {
});
});
group('liveness on the engine clock', () {
test('stale and drop follow Liveness and the fake clock, not wall time', () {
engine = SyncEngine(
clock: clock,
liveness: const Liveness(staleAfter: Duration(minutes: 5), dropAfter: Duration(minutes: 10)));
join('a', 'alice');
engine.applyReady('a', true);
engine.feed.drain();
t = t.add(const Duration(minutes: 5));
engine.tick();
expect(engine.members['a']!.inSet, isTrue, reason: 'not past staleAfter yet');
t = t.add(const Duration(seconds: 1));
engine.tick();
expect(engine.members['a']!.inSet, isFalse);
expect(engine.feed.drain().map((f) => f.action), ['stale']);
t = t.add(const Duration(minutes: 4, seconds: 59));
expect(engine.sweepDropped(), isEmpty, reason: 'exactly dropAfter is not past it');
t = t.add(const Duration(seconds: 1));
expect(engine.sweepDropped(), ['a']);
expect(engine.feed.drain().map((f) => f.action), ['leave']);
});
test('defaults are today\'s 15 s / 30 s', () {
expect(engine.staleAfter, const Duration(seconds: 15));
expect(engine.dropAfter, const Duration(seconds: 30));
expect(engine.liveness.pingInterval, const Duration(seconds: 5));
});
});
group('end of file', () {
setUp(() {
engine.media = const HelloMedia(name: 'f', size: 1, duration: 20);
+1 -1
View File
@@ -1,4 +1,4 @@
// restructure: rewrite in step 5 (D3: the watched rule moves from position coverage to time present)
// restructure: rewrite in step 6 (D3: the watched rule moves from position coverage to time present)
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
+2 -2
View File
@@ -519,7 +519,7 @@ void mountSyncRoute(
? '${req.url.host.contains(':') ? '[${req.url.host}]' : req.url.host}:${req.url.port}'
: (req.url.host.contains(':') ? '[${req.url.host}]' : req.url.host);
return WebSocketUpgrade((final ws) async {
ws.pingInterval = Duration(seconds: RelayConstants.pingIntervalSecs);
ws.pingInterval = hub.engine.liveness.pingInterval;
final firstText = Completer<String>();
String? token;
var welcomed = false;
@@ -534,7 +534,7 @@ void mountSyncRoute(
void handleIntent(String text) {
final session = hub.session;
final viewer = token == null ? null : session.tokens[token];
viewer?.touch();
viewer?.touch(hub.engine.now);
Map<String, Object?> decoded;
try {
final parsed = jsonDecode(text);
+6 -2
View File
@@ -65,9 +65,13 @@ class SyncServer {
/// The session log while running with a [logDir].
SessionLog? get sessionLog => _sessionLog;
/// [ytDlp] replaces the server's own (tests).
SyncServer(this.config, {YtDlp? ytDlp, this.loopbackOnly = false, this.logDir}) {
/// [ytDlp] replaces the server's own (tests). [clock] and [liveness]
/// go to the engine: a test passes a fake clock to step through the
/// silence limits ([Liveness]) without waiting.
SyncServer(this.config,
{YtDlp? ytDlp, this.loopbackOnly = false, this.logDir, SyncClock? clock, Liveness liveness = const Liveness()}) {
hub = ServerHub(sessions, config.secret,
engine: SyncEngine(clock: clock, liveness: liveness),
hostKey: config.hostKey,
jellyfin: config.jellyfin,
ytDlp: ytDlp ?? YtDlp(serverYtDlpDir(config.ytDlpDir), enabled: config.ytDlp));
+132
View File
@@ -0,0 +1,132 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence): both
// tests pin today's rules (a silent member is stale, then removed; a reset
// socket loses its member, so the redial is a new one, P1); with grace they
// expect `gone` and the same member back.
//
// A client reaching the server through FlakyProxy (DEVELOPMENT.md "Tests").
import 'dart:async';
import 'dart:convert';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_core/testing.dart';
import 'package:sync_relay_server/server.dart';
import 'package:test/test.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
/// A bare `/sync` client: hello, then a report every 100 ms (any frame
/// keeps a member fresh).
class _Client {
final WebSocketChannel ch;
final frames = <Map<String, Object?>>[];
bool ended = false;
Timer? _reports;
late final StreamSubscription<dynamic> _sub;
_Client(int port, {String resume = ''})
: ch = WebSocketChannel.connect(Uri.parse('ws://127.0.0.1:$port/sync')) {
ch.ready.then((_) {}, onError: (_) {});
_sub = ch.stream.listen((m) => frames.add(Map<String, Object?>.from(jsonDecode('$m') as Map)),
onDone: () => ended = true, onError: (_) => ended = true);
ch.sink.add(jsonEncode(HelloMessage(
proto: RelayConstants.protoVersion,
secret: 'shh',
user: const HelloUser(name: 'alice'),
media: const HelloMedia(name: 'f.bin', size: 1, duration: 10.0),
resume: resume)
.toJson()));
}
Map<String, Object?>? get welcome => frames.where((f) => f['t'] == 'welcome').firstOrNull;
String get token => welcome!['token'] as String;
String get memberId => welcome!['memberId'] as String;
void startReports() => _reports = Timer.periodic(const Duration(milliseconds: 100), (_) {
if (!ended) {
ch.sink.add(jsonEncode(const ReportMessage(position: 0, buffered: 0, eventId: 0).toJson()));
}
});
Future<void> close() async {
_reports?.cancel();
try {
await _sub.cancel();
} catch (_) {}
try {
await ch.sink.close().timeout(const Duration(seconds: 1));
} catch (_) {}
}
}
Future<void> until(bool Function() ok, String what, {Duration limit = const Duration(seconds: 6)}) async {
final end = DateTime.now().add(limit);
while (!ok()) {
if (DateTime.now().isAfter(end)) fail('$what: not within $limit');
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}
void main() {
late SyncServer server;
late FlakyProxy proxy;
final clients = <_Client>[];
setUp(() async {
// Short limits so the demo runs in a couple of seconds.
server = SyncServer(AppConfig(port: 0, secret: 'shh', name: 'test'),
liveness: const Liveness(staleAfter: Duration(milliseconds: 600), dropAfter: Duration(milliseconds: 1500)));
await server.start();
proxy = await FlakyProxy.start(server.boundPort);
});
tearDown(() async {
for (final c in clients) {
await c.close();
}
clients.clear();
await proxy.close();
await server.stop();
});
_Client dial({String resume = ''}) {
final c = _Client(proxy.port, resume: resume);
clients.add(c);
return c;
}
test('a blackholed client goes stale, then is removed; it never hears of it', () async {
final c = dial();
await until(() => c.welcome != null, 'welcome');
c.startReports();
final engine = server.hub.engine;
EngineMember? member() => engine.members[c.token];
await Future<void>.delayed(const Duration(milliseconds: 1200));
expect(engine.isStale(member()!), isFalse, reason: 'reports through the proxy keep it fresh');
proxy.last!.blackhole();
final seen = c.frames.length;
await until(() => member() != null && engine.isStale(member()!), 'stale');
await until(() => member() == null, 'removed');
expect(server.hub.sockets.containsKey(c.token), isFalse);
// Half-open: the server's close never reached the client.
await Future<void>.delayed(const Duration(milliseconds: 300));
expect(c.ended, isFalse);
expect(c.frames.length, seen);
expect(proxy.last!.isClosed, isFalse);
expect(proxy.last!.dropped, greaterThan(0));
});
test('a reset client sees the drop at once and redials; today it comes back as a new member', () async {
final c = dial();
await until(() => c.welcome != null, 'welcome');
final first = c.memberId;
proxy.last!.reset();
await until(() => c.ended, 'client sees the reset');
await until(() => !server.hub.engine.members.containsKey(c.token), 'server forgets the socket');
final again = dial(resume: c.token);
await until(() => again.welcome != null, 'welcome after redial');
expect(proxy.links, hasLength(2));
expect(again.memberId, isNot(first), reason: 'P1: no grace yet');
expect(server.hub.engine.members, hasLength(1));
});
}