feat: bind client interface cache to scope lifetimes
Retire lost scope bindings and confirmed deleted scope caches without confusing edge omission or local eviction with deletion. Preserve immutable call snapshots, same-lifetime Descriptor checks, and newer fetch ownership while recovering both observations after InterfaceMismatch. Validation: 41 runtime + 13 adapter tests; client/protocol/HTTP tests; workspace check, tests and all-target tests; Nix workspace Clippy -D warnings; warning-free workspace rustdoc; format and diff checks.
This commit is contained in:
@@ -13,13 +13,14 @@ use wip_client::{
|
||||
};
|
||||
use wip_http::http::{Response, StatusCode};
|
||||
use wip_http::{
|
||||
Limits, encode_fetch_interface_response, encode_observe_response,
|
||||
encode_protocol_error_response,
|
||||
Limits, decode_call_operation_request, decode_fetch_interface_request,
|
||||
encode_fetch_interface_response, encode_observe_response, encode_protocol_error_response,
|
||||
};
|
||||
use wip_protocol::{
|
||||
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
|
||||
Object, ObjectObservation, ObserveRequest, OperationDeclaration, ParameterDeclaration,
|
||||
ProtocolError, ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value,
|
||||
CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1,
|
||||
InterfaceDescriptor, InterfaceReference, InterfaceTarget, Object, ObjectObservation,
|
||||
ObserveRequest, OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
|
||||
ProtocolInteraction, ReturnDeclaration, Target, TypeExpr, Value,
|
||||
};
|
||||
|
||||
fn limits() -> Limits {
|
||||
@@ -74,6 +75,352 @@ fn descriptor(format: &str) -> InterfaceDescriptor {
|
||||
}
|
||||
}
|
||||
|
||||
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn observe(client: &mut Client, session: &wip_client::SessionId, path: &str, value: Object) {
|
||||
let request = client.refresh_object(session, path).unwrap();
|
||||
client
|
||||
.complete(
|
||||
request.id,
|
||||
encode_observe_response(
|
||||
&ObserveRequest {
|
||||
path: path.into(),
|
||||
depth: 0,
|
||||
},
|
||||
&ObjectObservation {
|
||||
object: value,
|
||||
children: None,
|
||||
},
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn fetch(
|
||||
client: &mut Client,
|
||||
session: &wip_client::SessionId,
|
||||
value: FetchInterfaceResponse,
|
||||
) -> Completion {
|
||||
let request = client
|
||||
.prepare_interface(session, value.interface.clone())
|
||||
.unwrap();
|
||||
let logical = FetchInterfaceRequest {
|
||||
interface: value.interface.clone(),
|
||||
};
|
||||
assert_eq!(
|
||||
decode_fetch_interface_request(request.request.body(), limits()).unwrap(),
|
||||
logical
|
||||
);
|
||||
client
|
||||
.complete(
|
||||
request.id,
|
||||
encode_fetch_interface_response(&logical, &value, limits()).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_accepts_same_name_interfaces_in_different_scopes() {
|
||||
let (mut client, session) = setup();
|
||||
let request = client.refresh_object(&session, "/item").unwrap();
|
||||
let completion = client
|
||||
.complete(
|
||||
request.id,
|
||||
response(
|
||||
StatusCode::OK,
|
||||
br#"{"object":{"name":"item","interfaces":[{"scope":"/","name":"math"},{"scope":"/item","name":"math"}]}}"#,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let expected = vec![root_reference("math"), reference("/item", "math")];
|
||||
assert!(matches!(
|
||||
completion,
|
||||
Completion::Object(observation)
|
||||
if observation.state == ObservationState::Fresh
|
||||
&& observation.object.as_ref().unwrap().interfaces == expected
|
||||
));
|
||||
let cached = client.object(&session, "/item").unwrap();
|
||||
assert_eq!(cached.state, ObservationState::Fresh);
|
||||
assert_eq!(cached.object.unwrap().interfaces, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_name_different_scopes_have_independent_requests_and_cached_descriptors() {
|
||||
let (mut client, session) = setup();
|
||||
let root = root_reference("math");
|
||||
let scoped = reference("/area", "math");
|
||||
let root_request = client
|
||||
.ensure_interface(&session, root.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let scoped_request = client
|
||||
.ensure_interface(&session, scoped.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(root_request.id, scoped_request.id);
|
||||
assert_eq!(
|
||||
decode_fetch_interface_request(root_request.request.body(), limits()).unwrap(),
|
||||
FetchInterfaceRequest {
|
||||
interface: root.clone(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
decode_fetch_interface_request(scoped_request.request.body(), limits()).unwrap(),
|
||||
FetchInterfaceRequest {
|
||||
interface: scoped.clone(),
|
||||
}
|
||||
);
|
||||
for selected in [&root, &scoped] {
|
||||
assert!(
|
||||
client
|
||||
.ensure_interface(&session, selected.clone())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert_eq!(client.in_flight_len(), 2);
|
||||
|
||||
// Reverse completion order must not supersede the other scope's request.
|
||||
for (request, selected, documentation, scope_ref, validator) in [
|
||||
(
|
||||
scoped_request,
|
||||
&scoped,
|
||||
"area math",
|
||||
Some("area-object"),
|
||||
Some(b"area-v1".to_vec()),
|
||||
),
|
||||
(root_request, &root, "root math", None, None),
|
||||
] {
|
||||
let mut expected_descriptor = descriptor(INTERFACE_FORMAT_V1);
|
||||
expected_descriptor.documentation = Some(wip_protocol::Documentation {
|
||||
summary: documentation.into(),
|
||||
details: None,
|
||||
});
|
||||
let value = FetchInterfaceResponse {
|
||||
interface: selected.clone(),
|
||||
scope_ref: scope_ref.map(str::to_owned),
|
||||
descriptor: expected_descriptor.clone(),
|
||||
validator: validator.clone(),
|
||||
};
|
||||
let completion = client
|
||||
.complete(
|
||||
request.id,
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: selected.clone(),
|
||||
},
|
||||
&value,
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(completion, Completion::Interface(_)));
|
||||
let cached = client.interface(&session, selected).unwrap();
|
||||
assert_eq!(cached.reference, *selected);
|
||||
assert_eq!(cached.descriptor, Some(expected_descriptor));
|
||||
assert_eq!(cached.scope_ref, value.scope_ref);
|
||||
assert_eq!(cached.validator, validator);
|
||||
assert_eq!(cached.state, ObservationState::Fresh);
|
||||
}
|
||||
for selected in [&root, &scoped] {
|
||||
assert!(
|
||||
client
|
||||
.ensure_interface(&session, selected.clone())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert_eq!(client.in_flight_len(), 0);
|
||||
assert_eq!(
|
||||
client
|
||||
.interface(&session, &root)
|
||||
.unwrap()
|
||||
.descriptor
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.documentation
|
||||
.as_ref()
|
||||
.map(|documentation| documentation.summary.as_str()),
|
||||
Some("root math")
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.interface(&session, &scoped)
|
||||
.unwrap()
|
||||
.descriptor
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.documentation
|
||||
.as_ref()
|
||||
.map(|documentation| documentation.summary.as_str()),
|
||||
Some("area math")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_completion_retains_optional_scope_ref_independently_of_validator() {
|
||||
for scope_ref in [None, Some("opaque scope identity / not a path")] {
|
||||
for validator in [None, Some(b"interface-v1".to_vec())] {
|
||||
let (mut client, session) = setup();
|
||||
let selected = reference("/area", "math");
|
||||
let expected = wip_client::InterfaceObservation {
|
||||
reference: selected.clone(),
|
||||
descriptor: Some(descriptor(INTERFACE_FORMAT_V1)),
|
||||
scope_ref: scope_ref.map(str::to_owned),
|
||||
validator: validator.clone(),
|
||||
state: ObservationState::Fresh,
|
||||
};
|
||||
let completion = fetch(
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: selected.clone(),
|
||||
descriptor: expected.descriptor.clone().unwrap(),
|
||||
scope_ref: expected.scope_ref.clone(),
|
||||
validator,
|
||||
},
|
||||
);
|
||||
assert_eq!(completion, Completion::Interface(expected.clone()));
|
||||
assert_eq!(client.interface(&session, &selected), Some(&expected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_membership_requires_the_complete_scope_and_name_pair() {
|
||||
let (mut client, session) = setup();
|
||||
let member = reference("/item", "math");
|
||||
let same_name = root_reference("math");
|
||||
let mut value = object();
|
||||
value.interfaces = vec![member.clone()];
|
||||
observe(&mut client, &session, "/item", value);
|
||||
for selected in [&member, &same_name] {
|
||||
fetch(
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: selected.clone(),
|
||||
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
||||
scope_ref: None,
|
||||
validator: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let arguments = BTreeMap::from([("value".into(), Value::Integer(2))]);
|
||||
assert_eq!(
|
||||
client
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/item",
|
||||
&same_name,
|
||||
"calculate",
|
||||
arguments.clone(),
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ClientErrorKind::InvalidRequest
|
||||
);
|
||||
assert_eq!(client.in_flight_len(), 0);
|
||||
let call = client
|
||||
.prepare_call(&session, "/item", &member, "calculate", arguments)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
decode_call_operation_request(
|
||||
call.request.body(),
|
||||
&descriptor(INTERFACE_FORMAT_V1),
|
||||
limits(),
|
||||
)
|
||||
.unwrap()
|
||||
.interface
|
||||
.reference,
|
||||
member
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calls_copy_exact_observed_references_metadata_and_validators_for_root_ancestor_and_self_scopes()
|
||||
{
|
||||
for (path, scope) in [
|
||||
("/", "/"),
|
||||
("/item", "/"),
|
||||
("/item", "/item"),
|
||||
("/area/item", "/area"),
|
||||
("/area/item", "/area/item"),
|
||||
] {
|
||||
for scope_ref in [None, Some("opaque scope identity / not a path")] {
|
||||
for interface_validator in [None, Some(b"interface-v1".to_vec())] {
|
||||
for object_validator in [None, Some(b"object-v1".to_vec())] {
|
||||
let (mut client, session) = setup();
|
||||
let selected = reference(scope, "math");
|
||||
let expected_descriptor = descriptor(INTERFACE_FORMAT_V1);
|
||||
let mut value = object();
|
||||
value.name = path.rsplit('/').next().unwrap().into();
|
||||
value.interfaces = vec![selected.clone()];
|
||||
value.validator = object_validator.clone();
|
||||
if path == scope {
|
||||
value.r#ref = scope_ref.map(str::to_owned);
|
||||
}
|
||||
observe(&mut client, &session, path, value.clone());
|
||||
fetch(
|
||||
&mut client,
|
||||
&session,
|
||||
FetchInterfaceResponse {
|
||||
interface: selected.clone(),
|
||||
descriptor: expected_descriptor.clone(),
|
||||
scope_ref: scope_ref.map(str::to_owned),
|
||||
validator: interface_validator.clone(),
|
||||
},
|
||||
);
|
||||
let expected_interface = client.interface(&session, &selected).unwrap().clone();
|
||||
let expected_object = client.object(&session, path).unwrap();
|
||||
let arguments = BTreeMap::from([("value".into(), Value::Integer(2))]);
|
||||
let expected = CallOperationRequest {
|
||||
target: Target {
|
||||
path: path.into(),
|
||||
validator: object_validator,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: selected.clone(),
|
||||
scope_ref: scope_ref.map(str::to_owned),
|
||||
validator: interface_validator.clone(),
|
||||
},
|
||||
operation: "calculate".into(),
|
||||
arguments: arguments.clone(),
|
||||
};
|
||||
let call = client
|
||||
.prepare_call(&session, path, &selected, "calculate", arguments)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
decode_call_operation_request(
|
||||
call.request.body(),
|
||||
&expected_descriptor,
|
||||
limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
let context = client.pending_call_context(call.id).unwrap();
|
||||
assert_eq!(context.request(), &expected);
|
||||
assert_eq!(context.interface(), &expected_interface);
|
||||
assert_eq!(context.object(), &expected_object);
|
||||
assert_eq!(context.object().object.as_ref(), Some(&value));
|
||||
assert_eq!(context.descriptor(), &expected_descriptor);
|
||||
assert_eq!(context.operation(), &expected_descriptor.operations[0]);
|
||||
assert_eq!(context.session(), &session);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_response() {
|
||||
let cases: &[&[u8]] = &[
|
||||
|
||||
@@ -2076,3 +2076,703 @@ fn structured_interface_keys_membership_and_scope_ref_are_preserved() {
|
||||
);
|
||||
assert_eq!(client.interface(&session, &root).unwrap().scope_ref, None);
|
||||
}
|
||||
|
||||
fn scoped_reference(scope: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: "math".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn scoped_descriptor(
|
||||
scope: &str,
|
||||
scope_ref: Option<&str>,
|
||||
validator: Option<&[u8]>,
|
||||
returns: TypeExpr,
|
||||
) -> FetchInterfaceResponse {
|
||||
FetchInterfaceResponse {
|
||||
interface: scoped_reference(scope),
|
||||
scope_ref: scope_ref.map(str::to_owned),
|
||||
validator: validator.map(<[u8]>::to_vec),
|
||||
descriptor: descriptor(returns),
|
||||
}
|
||||
}
|
||||
|
||||
fn interface_response(value: &FetchInterfaceResponse) -> Response<Vec<u8>> {
|
||||
encode_fetch_interface_response(
|
||||
&FetchInterfaceRequest {
|
||||
interface: value.interface.clone(),
|
||||
},
|
||||
value,
|
||||
wire_limits(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn failure_response(
|
||||
interaction: ProtocolInteraction,
|
||||
code: ProtocolErrorCode,
|
||||
) -> Response<Vec<u8>> {
|
||||
encode_protocol_error_response(
|
||||
interaction,
|
||||
&ProtocolError {
|
||||
code,
|
||||
message: "host failure".into(),
|
||||
},
|
||||
wire_limits(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn scope_client() -> (Client, wip_client::SessionId) {
|
||||
let mut client = client(8, 8, 8, 8);
|
||||
let session = client
|
||||
.open_session("https://example.test/wip", SecurityContext::new("subject"))
|
||||
.unwrap();
|
||||
let mut target = object("item", Some("target"), b"target-v1");
|
||||
target.interfaces = vec![scoped_reference("/scope")];
|
||||
observe_object(&mut client, &session, "/scope/item", target);
|
||||
(client, session)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_binding_updates_retire_descriptors_without_reattaching_new_refs() {
|
||||
for validator in [None, Some(b"same-validator".as_slice())] {
|
||||
for new_ref in [None, Some("replacement")] {
|
||||
let (mut client, session) = scope_client();
|
||||
let old = scoped_descriptor("/scope", Some("original"), validator, TypeExpr::Integer);
|
||||
observe_interface(&mut client, &session, old.clone());
|
||||
// A same-ref ordinary state update does not end the Interface lifetime.
|
||||
observe_object(
|
||||
&mut client,
|
||||
&session,
|
||||
"/scope",
|
||||
object("scope", Some("original"), b"v1"),
|
||||
);
|
||||
observe_object(
|
||||
&mut client,
|
||||
&session,
|
||||
"/scope",
|
||||
object("scope", Some("original"), b"v2"),
|
||||
);
|
||||
assert_eq!(
|
||||
client.interface(&session, &old.interface).unwrap().state,
|
||||
ObservationState::Fresh
|
||||
);
|
||||
let call = client
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/scope/item",
|
||||
&old.interface,
|
||||
"calculate",
|
||||
args(1),
|
||||
)
|
||||
.unwrap();
|
||||
let context = client.pending_call_context(call.id).unwrap().clone();
|
||||
observe_object(
|
||||
&mut client,
|
||||
&session,
|
||||
"/scope",
|
||||
object("scope", new_ref, b"v3"),
|
||||
);
|
||||
let cached = client.interface(&session, &old.interface).unwrap();
|
||||
assert_eq!(cached.state, ObservationState::Stale);
|
||||
assert!(cached.descriptor.is_none());
|
||||
assert_eq!(cached.scope_ref.as_deref(), Some("original"));
|
||||
assert_eq!(
|
||||
client
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/scope/item",
|
||||
&old.interface,
|
||||
"calculate",
|
||||
args(2)
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ClientErrorKind::MissingObservation
|
||||
);
|
||||
// Equal validators across lifetimes do not constrain the new Descriptor.
|
||||
let current = scoped_descriptor("/scope", new_ref, validator, TypeExpr::String);
|
||||
observe_interface(&mut client, &session, current.clone());
|
||||
let new_call = client
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/scope/item",
|
||||
&old.interface,
|
||||
"calculate",
|
||||
args(3),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.pending_call_context(new_call.id)
|
||||
.unwrap()
|
||||
.request()
|
||||
.interface
|
||||
.scope_ref,
|
||||
new_ref.map(str::to_owned)
|
||||
);
|
||||
assert_eq!(context.interface().scope_ref.as_deref(), Some("original"));
|
||||
assert_eq!(context.descriptor(), &old.descriptor);
|
||||
// An in-flight call keeps the old Integer result contract after retirement.
|
||||
let result = encode_call_operation_response(
|
||||
context.request(),
|
||||
context.descriptor(),
|
||||
&CallOperationResponse {
|
||||
result: Value::Integer(7),
|
||||
validator: None,
|
||||
},
|
||||
wire_limits(),
|
||||
)
|
||||
.unwrap();
|
||||
let Completion::Call(record) = client.complete(call.id, result).unwrap() else {
|
||||
panic!("call completion expected")
|
||||
};
|
||||
assert!(matches!(record.outcome, CallOutcome::Success(_)));
|
||||
assert_eq!(record.context.descriptor(), &old.descriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_scope_ref_changes_start_new_lifetimes_but_same_ref_preserves_validation() {
|
||||
for validator in [None, Some(b"reused".as_slice())] {
|
||||
let (mut client, session) = scope_client();
|
||||
let old = scoped_descriptor("/scope", Some("old"), validator, TypeExpr::Integer);
|
||||
observe_interface(&mut client, &session, old.clone());
|
||||
let contradictory = scoped_descriptor("/scope", Some("old"), validator, TypeExpr::String);
|
||||
let fetch = client
|
||||
.prepare_interface(&session, old.interface.clone())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.complete(fetch.id, interface_response(&contradictory))
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ClientErrorKind::InvalidResponse
|
||||
);
|
||||
for scope_ref in [Some("new"), None, Some("another")] {
|
||||
let current = scoped_descriptor("/scope", scope_ref, validator, TypeExpr::String);
|
||||
observe_interface(&mut client, &session, current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_refs_do_not_invent_identity_or_detect_unobservable_replacement() {
|
||||
let (mut client, session) = scope_client();
|
||||
let value = scoped_descriptor("/scope", None, None, TypeExpr::Integer);
|
||||
observe_interface(&mut client, &session, value.clone());
|
||||
for validator in [b"first".as_slice(), b"replaced".as_slice()] {
|
||||
observe_object(
|
||||
&mut client,
|
||||
&session,
|
||||
"/scope",
|
||||
object("scope", None, validator),
|
||||
);
|
||||
assert_eq!(
|
||||
client.interface(&session, &value.interface).unwrap().state,
|
||||
ObservationState::Fresh
|
||||
);
|
||||
}
|
||||
let call = client
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/scope/item",
|
||||
&value.interface,
|
||||
"calculate",
|
||||
args(1),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.pending_call_context(call.id)
|
||||
.unwrap()
|
||||
.request()
|
||||
.interface
|
||||
.scope_ref,
|
||||
None
|
||||
);
|
||||
let fetch = client
|
||||
.prepare_interface(&session, value.interface.clone())
|
||||
.unwrap();
|
||||
let changed = scoped_descriptor("/scope", None, None, TypeExpr::String);
|
||||
assert_eq!(
|
||||
client
|
||||
.complete(fetch.id, interface_response(&changed))
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ClientErrorKind::InvalidResponse
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_scope_deletion_discards_cache_and_rejects_old_fetch_and_descendant_observe() {
|
||||
let (mut client, session) = scope_client();
|
||||
let old = scoped_descriptor("/scope", Some("old"), None, TypeExpr::Integer);
|
||||
observe_interface(&mut client, &session, old.clone());
|
||||
let old_fetch = client
|
||||
.prepare_interface(&session, old.interface.clone())
|
||||
.unwrap();
|
||||
let old_child = client.refresh_object(&session, "/scope/item").unwrap();
|
||||
let deletion = client.refresh_object(&session, "/scope").unwrap();
|
||||
client
|
||||
.complete(
|
||||
deletion.id,
|
||||
failure_response(ProtocolInteraction::Observe, ProtocolErrorCode::NotFound),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(client.interface(&session, &old.interface).is_none());
|
||||
assert_eq!(
|
||||
client.object(&session, "/scope/item").unwrap().state,
|
||||
ObservationState::Stale
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.complete(old_fetch.id, interface_response(&old))
|
||||
.unwrap(),
|
||||
Completion::StaleResponseRejected(old_fetch.id)
|
||||
);
|
||||
let mut target = object("item", Some("target"), b"target-v1");
|
||||
target.interfaces = vec![old.interface.clone()];
|
||||
assert_eq!(
|
||||
client
|
||||
.complete(old_child.id, observation_response("/scope/item", &target))
|
||||
.unwrap(),
|
||||
Completion::StaleResponseRejected(old_child.id)
|
||||
);
|
||||
// Republishing even without refs is observable after direct NotFound.
|
||||
observe_object(
|
||||
&mut client,
|
||||
&session,
|
||||
"/scope",
|
||||
object("scope", None, b"new"),
|
||||
);
|
||||
let new = scoped_descriptor("/scope", None, None, TypeExpr::String);
|
||||
let fetch = client
|
||||
.ensure_interface(&session, old.interface.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
client.complete(fetch.id, interface_response(&new)).unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.interface(&session, &old.interface)
|
||||
.unwrap()
|
||||
.descriptor
|
||||
.as_ref(),
|
||||
Some(&new.descriptor)
|
||||
);
|
||||
assert_eq!(client.in_flight_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_replacement_supersedes_pending_fetch_including_first_fetch() {
|
||||
for already_cached in [false, true] {
|
||||
let (mut client, session) = scope_client();
|
||||
let old = scoped_descriptor("/scope", Some("old"), None, TypeExpr::Integer);
|
||||
if already_cached {
|
||||
observe_interface(&mut client, &session, old.clone());
|
||||
}
|
||||
let pending = client
|
||||
.prepare_interface(&session, old.interface.clone())
|
||||
.unwrap();
|
||||
observe_object(
|
||||
&mut client,
|
||||
&session,
|
||||
"/scope",
|
||||
object("scope", Some("new"), b"new"),
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.complete(pending.id, interface_response(&old))
|
||||
.unwrap(),
|
||||
Completion::StaleResponseRejected(pending.id)
|
||||
);
|
||||
assert_eq!(
|
||||
client.interface(&session, &old.interface).unwrap().state,
|
||||
ObservationState::Stale
|
||||
);
|
||||
observe_interface(
|
||||
&mut client,
|
||||
&session,
|
||||
scoped_descriptor("/scope", Some("new"), None, TypeExpr::String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_mismatch_stales_both_observations_but_not_a_newer_fetch() {
|
||||
for newer_fetch in [false, true] {
|
||||
let (mut client, session) = scope_client();
|
||||
let old = scoped_descriptor("/scope", Some("old"), None, TypeExpr::Integer);
|
||||
observe_interface(&mut client, &session, old.clone());
|
||||
let call = client
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/scope/item",
|
||||
&old.interface,
|
||||
"calculate",
|
||||
args(1),
|
||||
)
|
||||
.unwrap();
|
||||
let newer = newer_fetch.then(|| {
|
||||
client
|
||||
.prepare_interface(&session, old.interface.clone())
|
||||
.unwrap()
|
||||
});
|
||||
client
|
||||
.complete(
|
||||
call.id,
|
||||
failure_response(
|
||||
ProtocolInteraction::CallOperation,
|
||||
ProtocolErrorCode::InterfaceMismatch,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client.object(&session, "/scope/item").unwrap().state,
|
||||
ObservationState::Stale
|
||||
);
|
||||
let current = client.interface(&session, &old.interface).unwrap();
|
||||
if let Some(newer) = newer {
|
||||
assert_eq!(current.state, ObservationState::Loading(newer.id));
|
||||
client.complete(newer.id, interface_response(&old)).unwrap();
|
||||
} else {
|
||||
assert_eq!(current.state, ObservationState::Stale);
|
||||
assert_eq!(current.descriptor.as_ref(), Some(&old.descriptor));
|
||||
// No automatic retry, and same-binding consistency still applies.
|
||||
assert_eq!(client.in_flight_len(), 0);
|
||||
let contradictory = scoped_descriptor("/scope", Some("old"), None, TypeExpr::String);
|
||||
let fetch = client
|
||||
.prepare_interface(&session, old.interface.clone())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.complete(fetch.id, interface_response(&contradictory))
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ClientErrorKind::InvalidResponse
|
||||
);
|
||||
// A genuinely new binding can replace an immutable old Descriptor.
|
||||
observe_interface(
|
||||
&mut client,
|
||||
&session,
|
||||
scoped_descriptor("/scope", Some("new"), None, TypeExpr::String),
|
||||
);
|
||||
}
|
||||
assert_eq!(client.call_history(&session).unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_not_found_stales_target_but_only_direct_observe_confirms_scope_deletion() {
|
||||
let (mut client, session) = scope_client();
|
||||
let value = scoped_descriptor("/scope", Some("scope"), None, TypeExpr::Integer);
|
||||
observe_interface(&mut client, &session, value.clone());
|
||||
let call = client
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/scope/item",
|
||||
&value.interface,
|
||||
"calculate",
|
||||
args(1),
|
||||
)
|
||||
.unwrap();
|
||||
client
|
||||
.complete(
|
||||
call.id,
|
||||
failure_response(
|
||||
ProtocolInteraction::CallOperation,
|
||||
ProtocolErrorCode::NotFound,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client.object(&session, "/scope/item").unwrap().state,
|
||||
ObservationState::Stale
|
||||
);
|
||||
assert_eq!(
|
||||
client.interface(&session, &value.interface).unwrap().state,
|
||||
ObservationState::Fresh
|
||||
);
|
||||
assert_eq!(client.in_flight_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_movement_does_not_rekey_cache_and_root_deletion_is_segment_aware() {
|
||||
let (mut client, session) = scope_client();
|
||||
let old = scoped_descriptor("/scope", Some("scope-id"), None, TypeExpr::Integer);
|
||||
observe_interface(&mut client, &session, old.clone());
|
||||
observe_interface(
|
||||
&mut client,
|
||||
&session,
|
||||
scoped_descriptor("/scope-other", None, None, TypeExpr::Integer),
|
||||
);
|
||||
observe_object(
|
||||
&mut client,
|
||||
&session,
|
||||
"/moved",
|
||||
object("moved", Some("scope-id"), b"moved"),
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.interface(&session, &scoped_reference("/moved"))
|
||||
.is_none()
|
||||
);
|
||||
let deletion = client.refresh_object(&session, "/scope").unwrap();
|
||||
client
|
||||
.complete(
|
||||
deletion.id,
|
||||
failure_response(ProtocolInteraction::Observe, ProtocolErrorCode::NotFound),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(client.interface(&session, &old.interface).is_none());
|
||||
assert!(
|
||||
client
|
||||
.interface(&session, &scoped_reference("/scope-other"))
|
||||
.is_some()
|
||||
);
|
||||
let root = client.refresh_object(&session, "/").unwrap();
|
||||
client
|
||||
.complete(
|
||||
root.id,
|
||||
failure_response(ProtocolInteraction::Observe, ProtocolErrorCode::NotFound),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
client
|
||||
.interface(&session, &scoped_reference("/scope-other"))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_omission_and_local_scope_eviction_do_not_end_interface_lifetimes() {
|
||||
let mut client = client(2, 2, 8, 8);
|
||||
let session = client
|
||||
.open_session("https://example.test/wip", SecurityContext::new("subject"))
|
||||
.unwrap();
|
||||
let value = scoped_descriptor("/scope", Some("scope-id"), None, TypeExpr::Integer);
|
||||
observe_interface(&mut client, &session, value.clone());
|
||||
let request = ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: 1,
|
||||
};
|
||||
for children in [
|
||||
vec![TestObservation {
|
||||
object: object("scope", Some("scope-id"), b"scope"),
|
||||
children: vec![],
|
||||
}],
|
||||
vec![],
|
||||
] {
|
||||
let pending = client.refresh_children(&session, "/").unwrap();
|
||||
client
|
||||
.complete(
|
||||
pending.id,
|
||||
encode_test_observation_response(
|
||||
&request,
|
||||
&TestObservationResponse {
|
||||
root: TestObservation {
|
||||
object: object("", None, b"root"),
|
||||
children,
|
||||
},
|
||||
},
|
||||
wire_limits(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
client.object(&session, "/scope").unwrap().state,
|
||||
ObservationState::Stale
|
||||
);
|
||||
assert_eq!(
|
||||
client.interface(&session, &value.interface).unwrap().state,
|
||||
ObservationState::Fresh
|
||||
);
|
||||
for path in ["/zzz", "/zzzz"] {
|
||||
observe_object(
|
||||
&mut client,
|
||||
&session,
|
||||
path,
|
||||
object(path.trim_start_matches('/'), None, b"other"),
|
||||
);
|
||||
}
|
||||
assert!(client.object(&session, "/scope").is_none());
|
||||
assert_eq!(
|
||||
client.interface(&session, &value.interface).unwrap().state,
|
||||
ObservationState::Fresh
|
||||
);
|
||||
// Eviction was not evidence of deletion; consistency still holds.
|
||||
let pending = client
|
||||
.prepare_interface(&session, value.interface.clone())
|
||||
.unwrap();
|
||||
let changed = scoped_descriptor("/scope", Some("scope-id"), None, TypeExpr::String);
|
||||
assert_eq!(
|
||||
client
|
||||
.complete(pending.id, interface_response(&changed))
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ClientErrorKind::InvalidResponse
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_evidence_can_retire_a_retained_descriptor_while_a_later_refresh_loads() {
|
||||
for deleted in [false, true] {
|
||||
let (mut client, session) = scope_client();
|
||||
let old = scoped_descriptor("/scope", Some("old"), None, TypeExpr::Integer);
|
||||
observe_interface(&mut client, &session, old.clone());
|
||||
let scope_request = client.refresh_object(&session, "/scope").unwrap();
|
||||
let later_fetch = client
|
||||
.prepare_interface(&session, old.interface.clone())
|
||||
.unwrap();
|
||||
let response = if deleted {
|
||||
failure_response(ProtocolInteraction::Observe, ProtocolErrorCode::NotFound)
|
||||
} else {
|
||||
observation_response("/scope", &object("scope", Some("new"), b"new"))
|
||||
};
|
||||
client.complete(scope_request.id, response).unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.complete(later_fetch.id, interface_response(&old))
|
||||
.unwrap(),
|
||||
Completion::StaleResponseRejected(later_fetch.id)
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.interface(&session, &old.interface)
|
||||
.is_none_or(|cached| cached.state == ObservationState::Stale)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_keeps_in_flight_result_contract_and_isolated_session_cache() {
|
||||
let (mut client, session) = scope_client();
|
||||
let other = client
|
||||
.open_session(
|
||||
"https://example.test/wip",
|
||||
SecurityContext::new("other-tenant"),
|
||||
)
|
||||
.unwrap();
|
||||
let old = scoped_descriptor("/scope", Some("old"), None, TypeExpr::Integer);
|
||||
for id in [&session, &other] {
|
||||
observe_interface(&mut client, id, old.clone());
|
||||
}
|
||||
let call = client
|
||||
.prepare_call(
|
||||
&session,
|
||||
"/scope/item",
|
||||
&old.interface,
|
||||
"calculate",
|
||||
args(1),
|
||||
)
|
||||
.unwrap();
|
||||
let context = client.pending_call_context(call.id).unwrap().clone();
|
||||
let deletion = client.refresh_object(&session, "/scope").unwrap();
|
||||
client
|
||||
.complete(
|
||||
deletion.id,
|
||||
failure_response(ProtocolInteraction::Observe, ProtocolErrorCode::NotFound),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(client.interface(&session, &old.interface).is_none());
|
||||
assert_eq!(
|
||||
client.interface(&other, &old.interface).unwrap().state,
|
||||
ObservationState::Fresh
|
||||
);
|
||||
let response = encode_call_operation_response(
|
||||
context.request(),
|
||||
context.descriptor(),
|
||||
&CallOperationResponse {
|
||||
result: Value::Integer(42),
|
||||
validator: None,
|
||||
},
|
||||
wire_limits(),
|
||||
)
|
||||
.unwrap();
|
||||
let Completion::Call(record) = client.complete(call.id, response).unwrap() else {
|
||||
panic!("call expected")
|
||||
};
|
||||
assert!(matches!(record.outcome, CallOutcome::Success(_)));
|
||||
assert_eq!(record.context.descriptor(), &old.descriptor);
|
||||
assert!(client.interface(&session, &old.interface).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_response_scope_placement_is_validated_without_fetching_ancestors() {
|
||||
for scope in ["/sibling", "/item/child", "/ite"] {
|
||||
let (mut client, session) = scope_client();
|
||||
let request = client.refresh_object(&session, "/item").unwrap();
|
||||
let json = format!(
|
||||
r#"{{"object":{{"name":"item","interfaces":[{{"scope":"{scope}","name":"math"}}]}}}}"#
|
||||
);
|
||||
let response = Response::builder()
|
||||
.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(json.into_bytes())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client.complete(request.id, response).unwrap_err().kind,
|
||||
ClientErrorKind::InvalidResponse
|
||||
);
|
||||
assert_eq!(client.in_flight_len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_deletion_is_lifetime_evidence_even_without_any_refs() {
|
||||
let (mut client, session) = scope_client();
|
||||
let old = scoped_descriptor("/scope", None, None, TypeExpr::Integer);
|
||||
observe_interface(&mut client, &session, old.clone());
|
||||
let deletion = client.refresh_object(&session, "/scope").unwrap();
|
||||
client
|
||||
.complete(
|
||||
deletion.id,
|
||||
failure_response(ProtocolInteraction::Observe, ProtocolErrorCode::NotFound),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(client.interface(&session, &old.interface).is_none());
|
||||
let current = scoped_descriptor("/scope", None, None, TypeExpr::String);
|
||||
observe_interface(&mut client, &session, current.clone());
|
||||
assert_eq!(
|
||||
client
|
||||
.interface(&session, &old.interface)
|
||||
.unwrap()
|
||||
.descriptor
|
||||
.as_ref(),
|
||||
Some(¤t.descriptor)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_scope_evidence_does_not_retire_a_newer_successful_fetch() {
|
||||
let (mut client, session) = scope_client();
|
||||
let old_scope = client.refresh_object(&session, "/scope").unwrap();
|
||||
let current = scoped_descriptor("/scope", Some("new"), None, TypeExpr::String);
|
||||
observe_interface(&mut client, &session, current.clone());
|
||||
client
|
||||
.complete(
|
||||
old_scope.id,
|
||||
observation_response("/scope", &object("scope", Some("old"), b"old")),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.interface(&session, ¤t.interface)
|
||||
.unwrap()
|
||||
.state,
|
||||
ObservationState::Fresh
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.interface(&session, ¤t.interface)
|
||||
.unwrap()
|
||||
.scope_ref
|
||||
.as_deref(),
|
||||
Some("new")
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user