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:
2026-10-08 02:59:59 +09:00
parent 8ecc307fc9
commit 8b8041cba1
4 changed files with 1174 additions and 15 deletions
+352 -5
View File
@@ -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]] = &[