@@ -232,12 +232,9 @@ func LoadFile() (*File, error) {
232232// chmod'd to 0o700 and the file itself to 0o600 so other users on shared
233233// hosts can't read context URLs (or any future cached state).
234234//
235- // Note on concurrency: os.Rename is atomic against crashes but not against
236- // concurrent writers. Two twoctl invocations racing on `config.yaml` (e.g.
237- // a background `config use-context` while a foreground `auth login` runs)
238- // would lose the earlier update. We do not flock today; the race window is
239- // small and the realistic blast radius is "user re-runs the lost command".
240- // Tracked in INF-1318 (Han-9).
235+ // Callers that mutate config (SetContext / UseContext / DeleteContext)
236+ // wrap the load-modify-write cycle in withLock(); SaveFile itself is the
237+ // final atomic step (CreateTemp + Rename).
241238func SaveFile (f * File ) error {
242239 p , err := filePath ()
243240 if err != nil {
@@ -279,27 +276,35 @@ func SaveFile(f *File) error {
279276var contextNameRe = regexp .MustCompile (`^[a-z0-9][a-z0-9._-]{0,62}$` )
280277
281278// SetContext inserts or replaces a context. If apiKey is non-empty it is
282- // stored in the keychain under that context's account.
279+ // stored in the keychain under that context's account. The full read-
280+ // modify-write cycle holds an exclusive flock on config.yaml.lock so two
281+ // concurrent twoctl runs don't lose each other's updates.
283282func SetContext (name , baseURL , apiKey string ) error {
284283 if ! contextNameRe .MatchString (name ) {
285284 return fmt .Errorf ("invalid context name %q: must match %s" , name , contextNameRe )
286285 }
287- cfg , err := LoadFile ()
288- if err != nil {
289- return err
290- }
291- if baseURL == "" {
292- baseURL = inferURL (name )
293- }
294- clean , err := safeURL (baseURL )
286+ p , err := filePath ()
295287 if err != nil {
296288 return err
297289 }
298- cfg .Contexts [name ] = Context {Name : name , BaseURL : clean }
299- if cfg .CurrentContext == "" {
300- cfg .CurrentContext = name
301- }
302- if err := SaveFile (cfg ); err != nil {
290+ if err := withLock (p , func () error {
291+ cfg , err := LoadFile ()
292+ if err != nil {
293+ return err
294+ }
295+ if baseURL == "" {
296+ baseURL = inferURL (name )
297+ }
298+ clean , err := safeURL (baseURL )
299+ if err != nil {
300+ return err
301+ }
302+ cfg .Contexts [name ] = Context {Name : name , BaseURL : clean }
303+ if cfg .CurrentContext == "" {
304+ cfg .CurrentContext = name
305+ }
306+ return SaveFile (cfg )
307+ }); err != nil {
303308 return err
304309 }
305310 if apiKey != "" {
@@ -309,32 +314,45 @@ func SetContext(name, baseURL, apiKey string) error {
309314}
310315
311316// UseContext switches the current context. The context must already exist.
317+ // Held under config.yaml.lock for the read-modify-write window.
312318func UseContext (name string ) error {
313- cfg , err := LoadFile ()
319+ p , err := filePath ()
314320 if err != nil {
315321 return err
316322 }
317- if _ , ok := cfg .Contexts [name ]; ! ok {
318- return fmt .Errorf ("context %q does not exist (see `twoctl config get-contexts`)" , name )
319- }
320- cfg .CurrentContext = name
321- return SaveFile (cfg )
323+ return withLock (p , func () error {
324+ cfg , err := LoadFile ()
325+ if err != nil {
326+ return err
327+ }
328+ if _ , ok := cfg .Contexts [name ]; ! ok {
329+ return fmt .Errorf ("context %q does not exist (see `twoctl config get-contexts`)" , name )
330+ }
331+ cfg .CurrentContext = name
332+ return SaveFile (cfg )
333+ })
322334}
323335
324336// DeleteContext removes a context and its keychain entry.
325337func DeleteContext (name string ) error {
326- cfg , err := LoadFile ()
338+ p , err := filePath ()
327339 if err != nil {
328340 return err
329341 }
330- if _ , ok := cfg .Contexts [name ]; ! ok {
331- return fmt .Errorf ("context %q does not exist" , name )
332- }
333- delete (cfg .Contexts , name )
334- if cfg .CurrentContext == name {
335- cfg .CurrentContext = ""
336- }
337- if err := SaveFile (cfg ); err != nil {
342+ if err := withLock (p , func () error {
343+ cfg , err := LoadFile ()
344+ if err != nil {
345+ return err
346+ }
347+ if _ , ok := cfg .Contexts [name ]; ! ok {
348+ return fmt .Errorf ("context %q does not exist" , name )
349+ }
350+ delete (cfg .Contexts , name )
351+ if cfg .CurrentContext == name {
352+ cfg .CurrentContext = ""
353+ }
354+ return SaveFile (cfg )
355+ }); err != nil {
338356 return err
339357 }
340358 return DeleteKey (name )
0 commit comments