fix: retain observed scope bindings through client refresh states

Address MR review sequence 5: retain last accepted Object-ref evidence during loading, ordinary staleness and retrieval failure. Mark confirmed path unavailability separately within bounded Entry records so old lifetime evidence cannot contaminate republished ref-less descriptors. Compare retained Object refs on later replacement even when newer Interface ownership previously suppressed an older scope observation.

Add accepted-descriptor and pending-first-fetch old-to-new/ref-loss matrices during Loading/Error, edge-stale evidence, and confirmed-deletion/republication/reset tracking regressions. Validation: 50 runtime + 13 adapter tests; narrow Protocol/HTTP; workspace check/tests/all-target tests; Nix Clippy -D warnings; warning-free rustdoc; fmt/diff checks.
This commit is contained in:
2026-10-08 03:29:35 +09:00
parent fe6dccda83
commit b2be41d540
2 changed files with 244 additions and 3 deletions
+219
View File
@@ -3170,3 +3170,222 @@ fn corroborated_same_scope_ref_preserves_immutability_when_wire_metadata_becomes
let unchanged = scoped_descriptor("/scope", Some("same"), None, TypeExpr::Integer);
observe_interface(&mut client, &session, unchanged);
}
#[test]
fn retained_object_binding_survives_loading_and_failed_refresh_for_first_ref_less_fetch() {
for new_ref in [None, Some("new")] {
for accepted_descriptor in [false, true] {
for failed_refresh in [false, true] {
let (mut client, session) = scope_client();
observe_object(
&mut client,
&session,
"/scope",
object("scope", Some("old"), b"v1"),
);
let loading_scope = client.refresh_object(&session, "/scope").unwrap();
if failed_refresh {
client
.fail_transport(loading_scope.id, "temporary failure")
.unwrap_err();
}
let old = scoped_descriptor("/scope", None, None, TypeExpr::Integer);
let pending = client
.prepare_interface(&session, old.interface.clone())
.unwrap();
if accepted_descriptor {
client
.complete(pending.id, interface_response(&old))
.unwrap();
}
observe_object(
&mut client,
&session,
"/scope",
object("scope", new_ref, b"v2"),
);
let cached = client.interface(&session, &old.interface).unwrap();
assert_eq!(cached.state, ObservationState::Stale);
assert!(cached.descriptor.is_none());
assert_eq!(cached.scope_ref, None);
if !accepted_descriptor {
assert_eq!(
client
.complete(pending.id, interface_response(&old))
.unwrap(),
Completion::StaleResponseRejected(pending.id)
);
}
assert_eq!(
client
.prepare_call(
&session,
"/scope/item",
&old.interface,
"calculate",
args(1)
)
.unwrap_err()
.kind,
ClientErrorKind::MissingObservation
);
let current = scoped_descriptor("/scope", None, None, TypeExpr::String);
observe_interface(&mut client, &session, current.clone());
let call = client
.prepare_call(
&session,
"/scope/item",
&old.interface,
"calculate",
args(2),
)
.unwrap();
assert_eq!(
client
.pending_call_context(call.id)
.unwrap()
.request()
.interface
.scope_ref,
None
);
assert_eq!(
client.pending_call_context(call.id).unwrap().descriptor(),
&current.descriptor
);
if !failed_refresh {
assert_eq!(
client
.complete(
loading_scope.id,
observation_response(
"/scope",
&object("scope", Some("old"), b"v1")
)
)
.unwrap(),
Completion::StaleResponseRejected(loading_scope.id)
);
assert_eq!(
client.interface(&session, &old.interface).unwrap().state,
ObservationState::Fresh
);
}
}
}
}
}
#[test]
fn first_ref_less_descriptor_keeps_lifetime_evidence_when_scope_is_edge_stale() {
let (mut client, session) = scope_client();
let request = ObserveRequest {
path: "/".into(),
depth: 1,
};
for children in [
vec![TestObservation {
object: object("scope", Some("old"), 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
);
let old = scoped_descriptor("/scope", None, None, TypeExpr::Integer);
observe_interface(&mut client, &session, old.clone());
observe_object(
&mut client,
&session,
"/scope",
object("scope", Some("new"), b"new"),
);
assert_eq!(
client.interface(&session, &old.interface).unwrap().state,
ObservationState::Stale
);
assert!(
client
.interface(&session, &old.interface)
.unwrap()
.descriptor
.is_none()
);
}
#[test]
fn confirmed_missing_scope_does_not_corroborate_republished_descriptor_with_expired_ref() {
let (mut client, session) = scope_client();
observe_object(
&mut client,
&session,
"/scope",
object("scope", Some("old"), b"v1"),
);
let deletion = client.refresh_object(&session, "/scope").unwrap();
client
.complete(
deletion.id,
failure_response(ProtocolInteraction::Observe, ProtocolErrorCode::NotFound),
)
.unwrap();
let current = scoped_descriptor("/scope", None, None, TypeExpr::String);
observe_interface(&mut client, &session, current.clone());
// Loading state alone must not make the confirmed ended Object binding live.
let loading = client.refresh_object(&session, "/scope").unwrap();
observe_interface(&mut client, &session, current.clone());
client
.complete(
loading.id,
observation_response("/scope", &object("scope", Some("new"), b"v2")),
)
.unwrap();
assert_eq!(
client
.interface(&session, &current.interface)
.unwrap()
.state,
ObservationState::Fresh
);
assert_eq!(
client
.interface(&session, &current.interface)
.unwrap()
.scope_ref,
None
);
// A successful new observation restores tracking for subsequent replacements.
observe_object(
&mut client,
&session,
"/scope",
object("scope", Some("another"), b"v3"),
);
assert_eq!(
client
.interface(&session, &current.interface)
.unwrap()
.state,
ObservationState::Stale
);
}