11use serde:: { Deserialize , Serialize } ;
22use std:: collections:: HashSet ;
3+ use std:: io:: Write ;
34use std:: path:: { Path , PathBuf } ;
5+ use std:: sync:: atomic:: { AtomicU64 , Ordering } ;
46use std:: sync:: OnceLock ;
57
68static CONFIG_DIRECTORY : OnceLock < PathBuf > = OnceLock :: new ( ) ;
@@ -113,7 +115,7 @@ impl AppConfig {
113115 std:: fs:: create_dir_all ( parent)
114116 . map_err ( |e| format ! ( "无法创建配置目录({}):{}" , parent. display( ) , e) ) ?;
115117 }
116- std :: fs :: write ( path, content)
118+ atomic_write ( path, content. as_bytes ( ) )
117119 . map_err ( |e| format ! ( "无法保存配置({}):{}" , path. display( ) , e) )
118120 }
119121
@@ -126,10 +128,115 @@ impl AppConfig {
126128 }
127129}
128130
131+ /// Keep the previous config intact until the complete new file is flushed.
132+ /// A sibling temporary file keeps the replacement on the same filesystem;
133+ /// std::fs::rename replaces an existing file on both Windows and Unix.
134+ fn atomic_write ( path : & Path , content : & [ u8 ] ) -> std:: io:: Result < ( ) > {
135+ atomic_write_with ( path, |file| file. write_all ( content) )
136+ }
137+
138+ fn atomic_write_with (
139+ path : & Path ,
140+ write : impl FnOnce ( & mut std:: fs:: File ) -> std:: io:: Result < ( ) > ,
141+ ) -> std:: io:: Result < ( ) > {
142+ static NEXT_TEMP : AtomicU64 = AtomicU64 :: new ( 0 ) ;
143+ let name = path. file_name ( ) . ok_or_else ( || {
144+ std:: io:: Error :: new ( std:: io:: ErrorKind :: InvalidInput , "配置文件名不能为空" )
145+ } ) ?;
146+ // create_new never truncates a stale temporary file or another instance's
147+ // save. Retry collisions, but fail clearly rather than looping forever.
148+ let ( mut file, temporary) = ( 0 ..100 )
149+ . find_map ( |_| {
150+ let mut temporary_name = name. to_os_string ( ) ;
151+ temporary_name. push ( format ! (
152+ ".{}.{}.tmp" ,
153+ std:: process:: id( ) ,
154+ NEXT_TEMP . fetch_add( 1 , Ordering :: Relaxed )
155+ ) ) ;
156+ let temporary = path. with_file_name ( temporary_name) ;
157+ match std:: fs:: OpenOptions :: new ( )
158+ . write ( true )
159+ . create_new ( true )
160+ . open ( & temporary)
161+ {
162+ Ok ( file) => Some ( Ok ( ( file, temporary) ) ) ,
163+ Err ( error) if error. kind ( ) == std:: io:: ErrorKind :: AlreadyExists => None ,
164+ Err ( error) => Some ( Err ( error) ) ,
165+ }
166+ } )
167+ . unwrap_or_else ( || {
168+ Err ( std:: io:: Error :: new (
169+ std:: io:: ErrorKind :: AlreadyExists ,
170+ "无法创建唯一的临时配置文件" ,
171+ ) )
172+ } ) ?;
173+ let result = write ( & mut file) . and_then ( |_| file. sync_all ( ) ) ;
174+ // Windows replacement must not leave this process's temporary handle open.
175+ drop ( file) ;
176+ let result = result. and_then ( |_| std:: fs:: rename ( & temporary, path) ) ;
177+ if result. is_err ( ) {
178+ let _ = std:: fs:: remove_file ( & temporary) ;
179+ }
180+ result
181+ }
182+
129183#[ cfg( test) ]
130184mod tests {
131185 use super :: * ;
132186
187+ fn temp_directory ( label : & str ) -> PathBuf {
188+ static NEXT : AtomicU64 = AtomicU64 :: new ( 0 ) ;
189+ let path = std:: env:: temp_dir ( ) . join ( format ! (
190+ "game-accelerator-{label}-{}-{}" ,
191+ std:: process:: id( ) ,
192+ NEXT . fetch_add( 1 , Ordering :: Relaxed )
193+ ) ) ;
194+ std:: fs:: create_dir ( & path) . unwrap ( ) ;
195+ path
196+ }
197+
198+ #[ test]
199+ fn atomic_save_creates_and_replaces_config_in_unicode_directory ( ) {
200+ let directory = temp_directory ( "配置保存" ) ;
201+ let path = directory. join ( "config.toml" ) ;
202+ let mut config = AppConfig :: default ( ) ;
203+ config. save_to ( & path) . unwrap ( ) ;
204+ assert_eq ! ( AppConfig :: load_from( & path) . unwrap( ) , config) ;
205+ config. select_competitive_game ( "League of Legends.exe" ) ;
206+ config. save_to ( & path) . unwrap ( ) ;
207+ assert_eq ! ( AppConfig :: load_from( & path) . unwrap( ) , config) ;
208+ assert_eq ! ( std:: fs:: read_dir( & directory) . unwrap( ) . count( ) , 1 ) ;
209+ std:: fs:: remove_dir_all ( directory) . unwrap ( ) ;
210+ }
211+
212+ #[ test]
213+ fn interrupted_write_keeps_previous_config_and_removes_temporary_file ( ) {
214+ let directory = temp_directory ( "failed-write" ) ;
215+ let path = directory. join ( "config.toml" ) ;
216+ let original = b"previous valid config" ;
217+ std:: fs:: write ( & path, original) . unwrap ( ) ;
218+ let result = atomic_write_with ( & path, |file| {
219+ file. write_all ( b"incomplete replacement" ) ?;
220+ Err ( std:: io:: Error :: other ( "simulated disk failure" ) )
221+ } ) ;
222+ assert ! ( result. is_err( ) ) ;
223+ assert_eq ! ( std:: fs:: read( & path) . unwrap( ) , original) ;
224+ assert_eq ! ( std:: fs:: read_dir( & directory) . unwrap( ) . count( ) , 1 ) ;
225+ std:: fs:: remove_dir_all ( directory) . unwrap ( ) ;
226+ }
227+
228+ #[ test]
229+ fn failed_replacement_preserves_destination_and_cleans_temporary_file ( ) {
230+ let directory = temp_directory ( "failed-replace" ) ;
231+ let path = directory. join ( "config.toml" ) ;
232+ std:: fs:: create_dir ( & path) . unwrap ( ) ;
233+ std:: fs:: write ( path. join ( "keep" ) , b"original" ) . unwrap ( ) ;
234+ assert ! ( atomic_write( & path, b"new config" ) . is_err( ) ) ;
235+ assert_eq ! ( std:: fs:: read( path. join( "keep" ) ) . unwrap( ) , b"original" ) ;
236+ assert_eq ! ( std:: fs:: read_dir( & directory) . unwrap( ) . count( ) , 1 ) ;
237+ std:: fs:: remove_dir_all ( directory) . unwrap ( ) ;
238+ }
239+
133240 #[ test]
134241 fn first_run_does_not_manipulate_processes ( ) {
135242 let config = AppConfig :: default ( ) ;
0 commit comments