Repository navigation
feat(dart/agent): add RendererState; build validatePayload on it - #3276
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces RendererState to mirror the surfaces a renderer holds, enabling atomic message application and topology checking, and adds validatePayload for stateless payload validation. The A2uiRequestProcessor is updated to utilize these new utilities, and comprehensive tests are added. The review feedback recommends removing explicit type annotations on loop variables where the iterable already has an explicit type argument to comply with Dart's omit_obvious_local_variable_types lint.
1d84cf8 to
6019bc5
Compare
gspencergoog
left a comment
There was a problem hiding this comment.
Two medium-severity edge cases exist in the new payload validation helpers: _assumeSurface picks a candidate catalog using only the first message targeting an unknown surface, and validatePayload returns before checking catalogs when payload is empty. The core RendererState wrapper around MessageProcessor, transactional snapshot rollback, topology validation, and multi-block A2uiRequestProcessor.parseResponse integration are well structured and thoroughly tested.
🤖 Agent-generated
5089f63 to
57bf6f3
Compare
f5fc8f1 to
45c75d7
Compare
Add RendererState, the agent's mirror of the surfaces a renderer holds, which checks each message against everything applied before it. Build validatePayload on a short-lived RendererState that assumes a surface the payload only updates, and check a response in parseResponse as a whole against a new RendererState. Signed-off-by: Nan Yu <nanyu@google.com>
…e snapshots A failed apply or transaction used to restore a copy of each surface it had changed, taken the first time each frame changed it, so every apply to a surface copied the whole surface. The state now keeps an undo log, as the Python and TypeScript states do: - Each message records what it replaced: the components an updateComponents adds or replaces, with a copy of the previous properties and metadata of one updated in place; the data model value an updateDataModel overwrites, at the first segment of its path that holds no container yet, and a list's length when the write pads it; and a deleted surface, recorded whole and restored by replaying it through the processor with each component's own catalogId. - The state's own bookkeeping, the components changed since the last tree check and the assumed surfaces, is logged the same way. - A transaction is a position in the log, so a failed inner transaction undoes only what it applied and the outer one goes on. Applying 400 one-component messages to one surface takes 2.5 to 2.9 times as long as 100 (3.4 to 4.0 with snapshots), and 3.2 times inside one transaction (4.0 with snapshots); the 400-message runs take about a third less time. Signed-off-by: Nan Yu <nanyu@google.com>
45c75d7 to
291dd3e
Compare
polina-c
left a comment
There was a problem hiding this comment.
I reviewed commit 291dd3e, the undo log. I found two rollback bugs, and both reproduce at that commit. The inline comments explain them.
In dart/a2ui_agent at that commit, flutter test passes (1068 passed, 72 skipped), and so does dart test -P performance (12 passed). I also fuzzed rollback with random transactions that fail at the end, on v0.9 and two-catalog v1.0 surfaces. After each rollback I compared the state deeply with the state before it. Component properties, metadata, catalog ids, the data model and the assumed and changed sets always matched; only the component order differed (comment 2).
Repro file (copy it into dart/a2ui_agent/test/; all three tests fail at 291dd3e)
import 'package:a2ui_agent/a2ui_agent.dart';
import 'package:a2ui_core/a2ui_core.dart';
import 'package:test/test.dart';
final CatalogApi _basic = BasicCatalog.v0_9();
const _v = 'v0.9';
const _cat = BasicCatalog.v0_9Id;
Object? _nested(int depth) => depth == 0 ? 'leaf' : {'k': _nested(depth - 1)};
CatalogApi _catalog(String id, Map<String, Map<String, Object?>> types) =>
Catalog.fromJson({
'catalogId': id,
'protocolVersion': '1.0',
'components': {
for (final e in types.entries)
e.key: {'type': 'object', 'properties': e.value},
},
});
void main() {
test('1: rollback of a deleteSurface fails on a deep data model', () {
final state = RendererState([_basic]);
state.apply([
CreateSurfaceMessage(version: _v, surfaceId: 's', catalogId: _cat),
CreateSurfaceMessage(version: _v, surfaceId: 't', catalogId: _cat),
// Path 30 deep, value 30 deep: each message is within the limit of 50.
UpdateDataModelMessage(
version: _v,
surfaceId: 's',
path: '/${List.generate(30, (i) => 'p$i').join('/')}',
value: _nested(30),
),
]);
Object? caught;
try {
state.apply([
UpdateDataModelMessage(version: _v, surfaceId: 't', path: '/x', value: 1),
DeleteSurfaceMessage(version: _v, surfaceId: 's'),
// Rejected: the surface was just deleted.
UpdateComponentsMessage(version: _v, surfaceId: 's', components: [
{'id': 'root', 'component': 'Text', 'text': 'Hi'},
]),
]);
} catch (e) {
caught = e;
}
print('caught: $caught');
print("s data restored: ${state.getSurface('s')?.dataModel.get('/p0') != null}");
print("t /x after failed apply: ${state.getSurface('t')!.dataModel.get('/x')}");
expect(caught.toString(), contains('Surface not found'));
expect(state.getSurface('s')!.dataModel.get('/p0'), isNotNull);
expect(state.getSurface('t')!.dataModel.get('/x'), isNull);
});
test('2: undoing a type or catalog change moves the component to the end',
() {
final ref = {r'$ref': r'common_types.json#/$defs/ComponentId'};
RendererState make() => RendererState([
_catalog('urn:primary', {'Panel': {'text': {'type': 'string'}}}),
_catalog('urn:c', {'Box': {'link': ref}}),
_catalog('urn:d', {'Box': {'link': {'type': 'string'}, 'other': ref}}),
])
..apply([
CreateSurfaceMessage(
version: 'v1.0', surfaceId: 's', catalogId: 'urn:primary'),
UpdateComponentsMessage(version: 'v1.0', surfaceId: 's', components: [
{'id': 'root', 'component': 'Panel', 'text': 'hi'},
{'id': 'x', 'component': 'Box', 'catalogId': 'urn:c', 'link': 'y'},
{'id': 'd', 'component': 'Box', 'catalogId': 'urn:d', 'link': 'q'},
]),
]);
String order(RendererState s) =>
s.getSurface('s')!.componentsModel.keys.join(',');
String attempt(RendererState s, AgentToRendererMessage m) {
try {
s.apply([m]);
return 'accepted';
} catch (e) {
return 'rejected: $e';
}
}
final untouched = make();
final state = make();
print('before: ${order(state)}');
try {
state.transaction(() {
state.apply([
UpdateComponentsMessage(version: 'v1.0', surfaceId: 's', components: [
{'id': 'x', 'component': 'Box', 'catalogId': 'urn:d', 'link': 'y'},
]),
]);
throw StateError('a later message failed');
});
} on StateError {
// Rolled back.
}
print('after rollback: ${order(state)}');
// Under urn:c, Box.link is a reference, so y -> x closes x -> y -> x.
final cycle = UpdateComponentsMessage(version: 'v1.0', surfaceId: 's',
components: [
{'id': 'y', 'component': 'Box', 'catalogId': 'urn:c', 'link': 'x'},
]);
print('untouched state: ${attempt(untouched, cycle)}');
print('rolled-back state: ${attempt(state, cycle)}');
expect(order(state), startsWith('root,x,d'));
});
test('3: deleteSurface throws TypeError on a non-string-keyed data map', () {
final state = RendererState([_basic])
..apply([
CreateSurfaceMessage(version: _v, surfaceId: 's', catalogId: _cat),
UpdateDataModelMessage(
version: _v, surfaceId: 's', path: '/m', value: {1: 'one'}),
]);
Object? caught;
try {
state.apply([DeleteSurfaceMessage(version: _v, surfaceId: 's')]);
} catch (e) {
caught = e;
}
print('delete: ${caught ?? 'accepted'}');
expect(caught, isNull);
});
}🤖 Agent-generated
| ]; | ||
| return () { | ||
| if (getSurface(surfaceId) != null) return; | ||
| _processUnlogged(messages); |
There was a problem hiding this comment.
Undoing a deleteSurface replays the whole data model as one updateDataModel at /, and that message goes through the processor's message checks. One of them is the global depth limit of 50 in checkPathsAndRecursion. A data model built by writes at deep paths passes that check one message at a time but fails it when sent as one root value. The undo then throws partway through:
- The surface comes back with its components but without its data model.
_revertstops there, so the older records in the log are never undone, and theapplyis no longer atomic.- The caller gets
A2uiRecursionErrorinstead of the error that caused the rollback.
Test 1 in the repro file in the review summary: a data model at a 30-deep path holding a 30-deep value, then a failed apply of [updateDataModel on t, deleteSurface s, updateComponents s]:
caught: A2uiRecursionError [RECURSION_ERROR]: Global recursion limit exceeded: Depth > 50
s data restored: false
t /x after failed apply: 1
The same happens in transaction(() { apply([deleteSurface]); throw StateError('boom'); }): the caller gets the recursion error instead of the StateError. The snapshot version set the clone's data model directly, so it did not have this problem.
Could the undo replay only the create and the components, and then write the data straight to the model?
_processUnlogged(messages);
getSurface(surfaceId)!.dataModel.set('/', data);That also removes _deepCopy(data) on line 506. That copy throws TypeError at entry.key! as String when the data model holds a map with non-string keys, which a programmatically built UpdateDataModelMessage can store (test 3). Such a surface can't be deleted at all, while a bare MessageProcessor.forAgent deletes it.
It may also be worth making _revert finish undoing the rest of the log when one undo throws, and then rethrowing the original error, so a failing undo can't leave the log half undone.
🤖 Agent-generated
There was a problem hiding this comment.
Fixed in #3347 (commit efa0fba). Undoing a deleteSurface now replays only the create message and the components, then writes the data model straight back with dataModel.set('/', data), so the depth limit no longer applies to it (L586-L596). The data model is no longer copied when the delete is recorded, so a map with non-string keys no longer breaks it. _deepCopy, still used for properties, theme and metadata, now keeps maps with non-string keys (L903). _revert keeps undoing the rest of the log when one step throws, and the original error propagates (L272-L281, L778). Tests 1 and 3 of your repro are regression tests now (L766, L816). Falsy roots and a failing undo step have tests too (L837, L1001).
| // The update replaced the component with one of another type or | ||
| // catalog, and the core disposed of the old one. | ||
| components.removeComponent(record.id); | ||
| components.addComponent( |
There was a problem hiding this comment.
When an update changed a component's type or catalog, this undo removes the new component and adds the old one back, so the old one ends up last in the surface. The same branch runs for every component after a later deleteSurface is undone by replay, since the old ComponentModel objects are gone by then. After a rollback, the order differs from the state before it and from the renderer's. The snapshot version kept the order.
The order affects more than iteration order. The core merges reference fields by type name in the order of catalogIds, which follows component insertion order, so a rolled-back state can read a later batch with another catalog's reference fields. In test 2 of the repro file in the review summary, Box is a reference field in urn:c and a string in urn:d. After a rolled-back change of x from urn:c to urn:d, the state accepts a cycle that an untouched state rejects:
before: root,x,d
after rollback: root,d,x
untouched state: rejected: A2uiIntegrityError [INTEGRITY_ERROR]: Circular reference detected involving component 'x'
rolled-back state: accepted
The fuzzer found order-only differences like this in 10 of 3000 v0.9 seeds and in 68 and 184 of 4000 seeds in two v1.0 runs.
Putting the component back at its position probably needs a small core API, for example a SurfaceComponentsModel method that replaces a component in place or inserts one at a position (bumping _changes and resetting _catalogIds). Without one, the undo could re-append the components that followed the restored one, which costs O(surface) only on undo.
🤖 Agent-generated
There was a problem hiding this comment.
Fixed in #3347 (commit efa0fba) with a core API: SurfaceComponentsModel.insertComponent(index, component) adds a component at a position, bumps the change counter and resets catalogIds (L183-L205). The processor does not call it, so forward behavior is unchanged. When an entry may recreate a component (it names another type or catalog), the record stores the component's position. The positions are read once per message, O(surface), the same order of cost as the recreation itself. On undo, components that kept their type and catalog are restored in place, including the new objects a replayed deleteSurface creates. Recreated components are inserted back in ascending order of their old positions (L378-L490). Test 2 of your repro is a regression test, including the cycle check against an untouched state (L919). Two more tests cover several recreated components in one message and the order after an undone deleteSurface (L854, L888).
Stacked on #3328 (
ComponentGraphBoundina2ui_core). Part of #3263. Mirrors the Python change in #3268 and the TypeScript change in #3278.validatePayloadsin the Dart agent SDK created aMessageProcessorfor every response and disposed it at the end of the call. Surfaces started empty each time, so a response that updated a surface from an earlier turn was always rejected, and a canvas agent could not be checked at all.RendererState, exported frompackage:a2ui_agent/a2ui_agent.dart. It keeps oneMessageProcessor.forAgentfor its life, with dangling references, a missing root and orphans allowed while messages are applied, and unknown components still rejected.apply(messages)returns the touched surface ids. It is atomic: if a message is rejected, the state is left as it was.transaction(body)makes several calls atomic. Transactions nest: a failed inner transaction undoes only what it applied, and the outer one goes on. As before,bodymust be synchronous.checkTopology(surfaceIds)checks that the root exists and references resolve. Only components added or replaced since the last check must be reachable, so a component an update detached is accepted.catalogs,surfaceIds,getSurfaceanddispose().RendererStatesection describes, instead of copying a surface. A frame is a position in the log, and a failure reverts the records after it, latest first. Each message is run through_process, which records what it replaces. No surface is copied when a message is applied or a transaction opens.updateComponentsrecords the components it adds or replaces (_componentsRestorer). The core updates a component in place when its type and catalog stay the same, so the record holds a copy of the previous properties and metadata, and the undo restores them on the sameComponentModel. A component replaced by one of another type comes back as a newComponentModel.updateDataModelrecords the value at the first segment of its path that holds no container yet, and a list's length when the write pads it (_dataModelRestorer).catalogId, so a v1.0 surface whose components come from several catalogs comes back as it was (_surfaceRestorer).checkTopologyclears (_setAssumedto_forgetChanged).validatePayload(catalogs, payload)replaces the internalvalidatePayloads. It checks one payload on a short-lived state that assumes a surface the payload only updates exists, like Python'svalidate_payload.A2uiRequestProcessorchecks each example withvalidatePayload. An example that updates a surface it does not create is now accepted, as in Python.A2uiRequestProcessor.parseResponsechecks the whole response in a transaction on a new state, and checks every touched surface's tree at the end.Applying n one-component messages to one surface (
renderer_state_timing_test.dart, median of 5 runs):applyaloneapplyaloneThe Dart processor's own cost per message dominates at these sizes, so the copies did not yet show as a ratio near 16; they cost about a third of the time at 400 messages.
Testing
renderer_state_test.dart, andvalidation_test.dartupdated. The rollback cases cover a failed inner transaction inside one that goes on, an in-place update undone on the same objects, a type change, a surface deleted and created again, data model writes that create containers and pad lists, the changes a tree check cleared, and a v1.0 surface from two catalogs.renderer_state_timing_test.dart, taggedperformance: 400 against 100 messages, with and without a transaction, ratio at most 8.dart/a2ui_agentwith Flutter's Dart 3.13:dart format --output=none --set-exit-if-changed .,flutter analyze --fatal-infos(no issues),flutter test --test-randomize-ordering-seed=random(1068 passed, 72 skipped),dart test -P performance(12 passed).