fix: keep object observations fresh after successful calls

This commit is contained in:
2026-09-30 13:44:54 +09:00
parent de82b66de4
commit 2e4196a1ec
4 changed files with 153 additions and 26 deletions
+7 -4
View File
@@ -1261,11 +1261,14 @@ impl Client {
let session_id = context.session.clone(); let session_id = context.session.clone();
let maximum = self.limits.history; let maximum = self.limits.history;
let state = self.session_mut(&session_id)?; let state = self.session_mut(&session_id)?;
if let Some(object) = state.objects.get_mut(&context.object_key) { if let Some(object) = state.objects.get_mut(&context.object_key)
object.object.validator = value.validator.clone(); && object.revision <= id
object.revision = object.revision.max(id); {
if let Some(validator) = value.validator.clone() {
object.object.validator = Some(validator);
}
object.revision = id;
} }
state.mark_object_stale(context.object_key, id);
let record = CallRecord { let record = CallRecord {
request_id: id, request_id: id,
context, context,
+139 -7
View File
@@ -525,14 +525,147 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
client.interface(&session, "math").unwrap().descriptor, client.interface(&session, "math").unwrap().descriptor,
Some(descriptor(TypeExpr::String)) Some(descriptor(TypeExpr::String))
); );
let observation = client.object(&session, "/item").unwrap();
assert_eq!(observation.state, ObservationState::Fresh);
assert_eq!(observation.validator, Some(b"object-v2".to_vec()));
}
#[test]
fn successful_call_without_validator_preserves_observed_validator() {
let mut client = client(8, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
observe_object(
&mut client,
&session,
"/item",
object("item", None, b"object-v1"),
);
observe_interface(
&mut client,
&session,
FetchInterfaceResponse {
interface: "math".into(),
descriptor: descriptor(TypeExpr::Integer),
validator: None,
},
);
let call = client
.prepare_call(&session, "/item", "math", "calculate", args(1))
.unwrap();
let request = client
.pending_call_context(call.id)
.unwrap()
.request()
.clone();
let fixed_descriptor = client
.pending_call_context(call.id)
.unwrap()
.descriptor()
.clone();
client.mark_dispatched(call.id).unwrap();
client
.complete(
call.id,
encode_call_operation_response(
&request,
&fixed_descriptor,
&CallOperationResponse {
result: Value::Integer(2),
validator: None,
},
wire_limits(),
)
.unwrap(),
)
.unwrap();
let observation = client.object(&session, "/item").unwrap();
assert_eq!(observation.state, ObservationState::Fresh);
assert_eq!(observation.validator, Some(b"object-v1".to_vec()));
let next = client
.prepare_call(&session, "/item", "math", "calculate", args(2))
.unwrap();
assert_eq!( assert_eq!(
client.object(&session, "/item").unwrap().state, client
ObservationState::Stale .pending_call_context(next.id)
.unwrap()
.request()
.target
.validator,
Some(b"object-v1".to_vec())
); );
} }
#[test] #[test]
fn older_retrieval_cannot_restore_freshness_after_operation_completion() { fn newer_observation_prevents_older_success_validator_overwrite() {
let mut client = client(8, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
observe_object(
&mut client,
&session,
"/item",
object("item", None, b"object-v1"),
);
observe_interface(
&mut client,
&session,
FetchInterfaceResponse {
interface: "math".into(),
descriptor: descriptor(TypeExpr::Integer),
validator: None,
},
);
let call = client
.prepare_call(&session, "/item", "math", "calculate", args(1))
.unwrap();
let request = client
.pending_call_context(call.id)
.unwrap()
.request()
.clone();
let fixed_descriptor = client
.pending_call_context(call.id)
.unwrap()
.descriptor()
.clone();
client.mark_dispatched(call.id).unwrap();
observe_object(
&mut client,
&session,
"/item",
object("item", None, b"object-v3"),
);
client
.complete(
call.id,
encode_call_operation_response(
&request,
&fixed_descriptor,
&CallOperationResponse {
result: Value::Integer(2),
validator: Some(b"object-v2".to_vec()),
},
wire_limits(),
)
.unwrap(),
)
.unwrap();
let observation = client.object(&session, "/item").unwrap();
assert_eq!(observation.state, ObservationState::Fresh);
assert_eq!(observation.validator, Some(b"object-v3".to_vec()));
}
#[test]
fn older_retrieval_cannot_overwrite_success_validator() {
let mut client = client(8, 8, 8, 8); let mut client = client(8, 8, 8, 8);
let session = client let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject")) .open_session("https://example.test/wip", SecurityContext::new("subject"))
@@ -583,10 +716,9 @@ fn older_retrieval_cannot_restore_freshness_after_operation_completion() {
.unwrap(), .unwrap(),
) )
.unwrap(); .unwrap();
assert_eq!( let current = client.object(&session, "/item").unwrap();
client.object(&session, "/item").unwrap().state, assert_eq!(current.state, ObservationState::Fresh);
ObservationState::Stale assert_eq!(current.validator, Some(b"object-v2".to_vec()));
);
client client
.complete( .complete(
+2 -2
View File
@@ -121,8 +121,8 @@ layout.
## Validator and failure behavior ## Validator and failure behavior
Object and interface validators are observed and attached automatically by Object and interface validators are observed and attached automatically by
`wip-client`; users neither copy nor edit them. A successful operation conservatively `wip-client`; users neither copy nor edit them. A successful operation may update the
marks related object observations stale because metadata may have changed. cached Object validator but does not by itself make the Object observation stale.
Validator mismatch, interface mismatch, `OperationOutcomeUnknown`, undecodable Validator mismatch, interface mismatch, `OperationOutcomeUnknown`, undecodable
post-dispatch responses, and post-dispatch timeout/disconnect are displayed using post-dispatch responses, and post-dispatch timeout/disconnect are displayed using
the runtime's authoritative transition. the runtime's authoritative transition.
+5 -13
View File
@@ -91,11 +91,7 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
.contains("68656c6c6f20776f726c64") .contains("68656c6c6f20776f726c64")
); );
// Calls conservatively stale the object even for a read operation; refresh assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Fresh);
// before constructing the separately confirmed write call.
runtime.app_mut().focus = Focus::Tree;
runtime.handle_key(InputKey::Refresh);
settle(&mut runtime);
runtime.app_mut().focus = Focus::Operations; runtime.app_mut().focus = Focus::Operations;
// Load the separately-qualified write interface and replace with the // Load the separately-qualified write interface and replace with the
@@ -112,15 +108,11 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
std::fs::read(temporary.path().join("hello.txt")).expect("updated file"), std::fs::read(temporary.path().join("hello.txt")).expect("updated file"),
b"updated" b"updated"
); );
assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Stale);
// Reobserve, prepare a replacement from that observation, then mutate the
// file externally before explicit execution. The stale validator is rejected,
// the Entry becomes stale, and no automatic call is created.
runtime.app_mut().focus = Focus::Tree;
runtime.handle_key(InputKey::Refresh);
settle(&mut runtime);
assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Fresh); assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Fresh);
// Prepare another replacement using the post-operation validator, then mutate
// the file externally before explicit execution. The stale validator is rejected,
// the Entry becomes stale, and no automatic call is created.
runtime.app_mut().focus = Focus::Operations; runtime.app_mut().focus = Focus::Operations;
runtime.app_mut().operation_cursor = 3; runtime.app_mut().operation_cursor = 3;
runtime.handle_key(InputKey::Enter); runtime.handle_key(InputKey::Enter);