@@ -21,10 +21,10 @@ use crate::domain::{normalize_comment, PublicKey};
2121
2222use self :: client_manager:: { acquire_lifecycle_lock, RemoveClientError } ;
2323
24- /// Serializes expiration edits with automated expiration cleanup. The native
25- /// lifecycle lock still protects server/config rewrites; this lock closes the
26- /// smaller in-process race between revalidating a deadline and starting that
27- /// rewrite .
24+ /// Serializes expiration edits with creation metadata persistence and
25+ /// automated expiration cleanup. The native lifecycle lock still protects
26+ /// server/config rewrites; this lock closes the smaller in-process races at
27+ /// the database/lifecycle boundary .
2828static EXPIRATION_STATE_LOCK : tokio:: sync:: Mutex < ( ) > = tokio:: sync:: Mutex :: const_new ( ( ) ) ;
2929
3030fn map_lock_error ( err : std:: io:: Error ) -> RemoveClientError {
@@ -184,8 +184,8 @@ pub async fn execute_suggest_ips(
184184/// 4. Logs `user_created` or `user_create_failed`.
185185///
186186/// The caller is responsible for triggering a config rescan after success.
187- // The test -only native-result injection adds an eighth parameter; production
188- // retains the existing seven-argument lifecycle boundary .
187+ // Test -only native-result and lock-acquisition injections extend this
188+ // boundary; production retains the existing seven parameters .
189189#[ cfg_attr( test, allow( clippy:: too_many_arguments) ) ]
190190pub async fn execute_create_user (
191191 db : & Database ,
@@ -196,6 +196,7 @@ pub async fn execute_create_user(
196196 actor : & str ,
197197 ip_override : & client_manager:: IpOverride ,
198198 #[ cfg( test) ] create_result_override : Option < client_manager:: CreateClientResult > ,
199+ #[ cfg( test) ] expiration_lock_acquired : Option < tokio:: sync:: oneshot:: Sender < ( ) > > ,
199200) -> Result < CreateUserResult , client_manager:: CreateClientError > {
200201 // Pre-validate name (fail fast for the UI).
201202 script_bridge:: validate_client_name ( name) ?;
@@ -241,7 +242,16 @@ pub async fn execute_create_user(
241242 }
242243 } ;
243244
244- // Keep a single lifecycle lock held across native creation and expiration
245+ // Expiration state always precedes the native lifecycle lock. Keep both
246+ // held through metadata persistence so an edit cannot land on a
247+ // poller-created row and then be overwritten by the creation upsert.
248+ let _expiration_guard = EXPIRATION_STATE_LOCK . lock ( ) . await ;
249+ #[ cfg( test) ]
250+ if let Some ( acquired) = expiration_lock_acquired {
251+ let _ = acquired. send ( ( ) ) ;
252+ }
253+
254+ // Keep a single lifecycle lock held across native creation and metadata
245255 // persistence. If the database write fails, rollback runs under the same
246256 // lock, so no concurrent add/remove can strand or replace this client.
247257 #[ cfg( test) ]
@@ -918,6 +928,7 @@ mod tests {
918928 "test-admin" ,
919929 & client_manager:: IpOverride :: default ( ) ,
920930 Some ( created_client_result ( true ) ) ,
931+ None ,
921932 )
922933 . await
923934 . expect ( "durable creation must remain a success" ) ;
@@ -978,6 +989,7 @@ mod tests {
978989 "test-admin" ,
979990 & client_manager:: IpOverride :: default ( ) ,
980991 Some ( created_client_result ( true ) ) ,
992+ None ,
981993 )
982994 . await ;
983995
@@ -1095,4 +1107,83 @@ mod tests {
10951107 assert_eq ! ( persisted. has_config, 1 ) ;
10961108 assert_eq ! ( persisted. sync_pending, 1 ) ;
10971109 }
1110+
1111+ #[ tokio:: test]
1112+ async fn expiration_edit_waits_for_creation_metadata_persistence ( ) {
1113+ let db = Database :: connect_for_test ( ) . await . expect ( "connect" ) ;
1114+ sqlx:: query (
1115+ "INSERT INTO peers (public_key, allowed_ips)
1116+ VALUES ('CREATED_PUBLIC_KEY=', '10.66.66.2/32')" ,
1117+ )
1118+ . execute ( & db. pool )
1119+ . await
1120+ . expect ( "seed poller-created peer" ) ;
1121+ let peer = find_by_public_key ( & db. pool , "CREATED_PUBLIC_KEY=" )
1122+ . await
1123+ . expect ( "query peer" )
1124+ . expect ( "seeded peer" ) ;
1125+
1126+ // Hold the config-mapping lock so creation pauses after taking the
1127+ // expiration lock but before its upsert can overwrite the seeded row.
1128+ let mapping_guard = crate :: poller:: acquire_config_mapping_lock ( ) . await ;
1129+ let task_db = db. clone ( ) ;
1130+ let dir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
1131+ let dir_path = dir. path ( ) . to_path_buf ( ) ;
1132+ let ( lock_acquired_tx, lock_acquired_rx) = tokio:: sync:: oneshot:: channel ( ) ;
1133+ let create_task = tokio:: spawn ( async move {
1134+ let ip_override = client_manager:: IpOverride :: default ( ) ;
1135+ execute_create_user (
1136+ & task_db,
1137+ & dir_path,
1138+ "alice" ,
1139+ None ,
1140+ None ,
1141+ "test-admin" ,
1142+ & ip_override,
1143+ Some ( created_client_result ( false ) ) ,
1144+ Some ( lock_acquired_tx) ,
1145+ )
1146+ . await
1147+ } ) ;
1148+
1149+ lock_acquired_rx
1150+ . await
1151+ . expect ( "creation must acquire the expiration lock before persistence" ) ;
1152+
1153+ let update_db = db. clone ( ) ;
1154+ let update_task = tokio:: spawn ( async move {
1155+ execute_update_peer_expiration (
1156+ & update_db,
1157+ peer. id ,
1158+ Some ( "2026-08-18T12:00:00Z" ) ,
1159+ Some ( "alice" ) ,
1160+ )
1161+ . await
1162+ } ) ;
1163+ tokio:: task:: yield_now ( ) . await ;
1164+ assert ! (
1165+ !update_task. is_finished( ) ,
1166+ "expiration edit must wait until creation metadata is durable"
1167+ ) ;
1168+
1169+ drop ( mapping_guard) ;
1170+ create_task
1171+ . await
1172+ . expect ( "creation task" )
1173+ . expect ( "create user" ) ;
1174+ update_task
1175+ . await
1176+ . expect ( "expiration task" )
1177+ . expect ( "update expiration" )
1178+ . expect ( "updated peer" ) ;
1179+
1180+ let persisted = find_by_public_key ( & db. pool , "CREATED_PUBLIC_KEY=" )
1181+ . await
1182+ . expect ( "query peer" )
1183+ . expect ( "persisted peer" ) ;
1184+ assert_eq ! (
1185+ persisted. expires_at. as_deref( ) ,
1186+ Some ( "2026-08-18T12:00:00Z" )
1187+ ) ;
1188+ }
10981189}
0 commit comments