-
-
Couldn't load subscription status.
- Fork 106
Description
Contract State Not Persisted After PUT Merge
Summary
When a peer receives a PUT request for a contract it already has cached, the updated state is computed correctly but never persisted to the state store. This causes the peer to continue serving stale cached state even after receiving and validating updates from the network.
Environment
- Freenet version: 0.1.32
- Mode:
freenet network - Contract type: WebApp container (River)
Steps to Reproduce
- Start Freenet in network mode:
freenet network - Publish a contract (e.g., River webapp):
fdev publish --code ... contract --webapp-archive ... - Access the contract via HTTP to cache it locally
- Republish the same contract with updated state (larger size)
- Access the contract again via HTTP
Expected: The updated state (new version) is served
Actual: The old cached state is still served
Root Cause Analysis
Code Location
crates/core/src/contract/executor/runtime.rs:36-316 - upsert_contract_state()
The Bug
When upsert_contract_state is called for an existing contract:
-
Line 258: Gets current state from
state_storelet current_state = match self.state_store.get(&key).await { Ok(s) => s, ... };
-
Line 280: Calls
attempt_state_updateto compute merged statelet updated_state = match self .attempt_state_update(¶ms, ¤t_state, &key, &updates) .await?
-
Line 296-306: Validates the updated state and returns result
match self.runtime.validate_state(&key, ¶ms, &updated_state, &related_contracts) { ValidateResult::Valid => { if updated_state.as_ref() == current_state.as_ref() { Ok(UpsertResult::NoChange) } else { Ok(UpsertResult::Updated(updated_state)) // ⚠️ State NOT stored! } } }
The updated state is never written back to state_store!
Comparison: Working Path
In contrast, attempt_state_update (line 863-866) does persist the state:
self.state_store
.update(key, new_state.clone())
.await
.map_err(ExecutorError::other)?;But upsert_contract_state returns the updated state without storing it, relying on the caller to persist it. However, the caller in contract/mod.rs:110-125 just passes the state in a response event without storing it.
Observed Behavior
Debug Log Evidence
[2025-10-26T23:00:16] DEBUG freenet::contract::executor::runtime:
upserting contract state
contract: BcfxyjCH4snaknrBoCiqhYc9UFvmiJvhsp5d4L5DuvRa
state_size: 2218768 ← New incoming state
[2025-10-26T23:00:16] INFO freenet::operations::put:
Forwarding PUT to target peer
tx: 01K8HC0G1SM641DAMM7E64RA01
target_peer: v6MWKgqHiBMNcGtG
[2025-10-26T23:00:26] DEBUG freenet::contract::executor::runtime:
fetched contract state
contract: BcfxyjCH4snaknrBoCiqhYc9UFvmiJvhsp5d4L5DuvRa
state_size: 2134337 ← Old cached state still being served!
state_hash: 6044f16041b4891bb8f81b0bfaa42f09f4331ec2ade81bd316a30b699ee232b1
The state size shows:
- Incoming PUT: 2,218,768 bytes (new version)
- Cached GET: 2,134,337 bytes (old version)
Proposed Fix
Add state persistence to upsert_contract_state when returning Updated:
ValidateResult::Valid => {
if updated_state.as_ref() == current_state.as_ref() {
Ok(UpsertResult::NoChange)
} else {
// Store the updated state before returning
self.state_store
.update(&key, updated_state.clone())
.await
.map_err(ExecutorError::other)?;
Ok(UpsertResult::Updated(updated_state))
}
}Alternatively, ensure the caller in contract/mod.rs persists the state after receiving UpsertResult::Updated.
Impact
- High: Peers continue serving stale contract state even after receiving valid updates
- Affects all contract types when running in network mode
- Creates inconsistency between peers in the network
- Users see old versions of webapps/contracts despite successful publication
Related Code
crates/core/src/contract/executor/runtime.rs:36-upsert_contract_statecrates/core/src/contract/executor/runtime.rs:750-perform_contract_putcrates/core/src/contract/mod.rs:110- Caller that handlesUpsertResultcrates/core/src/contract/executor/runtime.rs:863- Working example inattempt_state_update
Metadata
Metadata
Assignees
Labels
Type
Projects
Status