{"record":{"id":"9beddf23514cb470","repo":"hashicorp/terraform","slug":"localstate-not-locked","errorCode":null,"errorMessage":"LocalState not locked","messagePattern":"LocalState not locked","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/command/clistate/local_state.go","lineNumber":214,"sourceCode":"\n\t\tlockErr := &statemgr.LockError{\n\t\t\tInfo: info,\n\t\t\tErr:  err,\n\t\t}\n\n\t\treturn \"\", lockErr\n\t}\n\n\ts.lockID = info.ID\n\treturn s.lockID, s.writeLockInfo(info)\n}\n\nfunc (s *LocalState) Unlock(id string) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif s.lockID == \"\" {\n\t\treturn fmt.Errorf(\"LocalState not locked\")\n\t}\n\n\tif id != s.lockID {\n\t\tidErr := fmt.Errorf(\"invalid lock id: %q. current id: %q\", id, s.lockID)\n\t\tinfo, err := s.lockInfo()\n\t\tif err != nil {\n\t\t\tidErr = errors.Join(idErr, err)\n\t\t}\n\n\t\treturn &statemgr.LockError{\n\t\t\tErr:  idErr,\n\t\t\tInfo: info,\n\t\t}\n\t}\n\n\tos.Remove(s.lockInfoPath())\n\n\tfileName := s.stateFileOut.Name()","sourceCodeStart":196,"sourceCodeEnd":232,"githubUrl":"https://github.com/hashicorp/terraform/blob/c9def3e214014c1188faabfc4a5bde5095139765/internal/command/clistate/local_state.go#L196-L232","documentation":"Returned by LocalState.Unlock (local_state.go:209) when s.lockID == \"\", i.e. the in-process lock tracking field indicates no lock is currently held. Because Unlock is meant to release a previously acquired Lock, calling it on an unlocked state is a programming error. The guard runs under s.mu, so it is race-free within the instance.","triggerScenarios":"Calling Unlock without a prior successful Lock; calling Unlock twice (double-unlock) because the first call already cleared s.lockID; calling Unlock after Lock failed and the caller assumes a lock exists; cleanup code that unconditionally unlocks regardless of whether Lock succeeded.","commonSituations":"A deferred Unlock firing after a Lock that errored; `defer s.Unlock(\"\")` left over from a refactor; a backend wrapper that unlocks in a finally block regardless of lock acquisition; test teardown unlocking state that was never locked.","solutions":["Only call Unlock when Lock returned a non-empty id; capture the id and guard the unlock: `if id != \"\" { s.Unlock(id) }`.","Use a sentinel (empty string) check before Unlock: `if s.lockID != \"\" { ... }` — but prefer the captured-id pattern.","Avoid double-unlock by clearing your own reference after Unlock: `id := s.lockID; s.Unlock(id); id = \"\"`.","In cleanup code, track lock ownership explicitly rather than unconditionally unlocking."],"exampleFix":"// before\nid, err := s.Lock(info)\n// ... work ...\ns.Unlock(id)\ns.Unlock(id) // LocalState not locked\n\n// after\nid, err := s.Lock(info)\nif err != nil { return err }\ndefer func() { s.Unlock(id); id = \"\" }()","handlingStrategy":"validation","validationCode":"// Only Unlock if we hold a lock\nif id == \"\" || !s.IsLocked() {\n    return nil\n}\nreturn s.Unlock(id)","typeGuard":"// IsLocked reports whether this LocalState instance currently holds the in-process lock.\nfunc (s *LocalState) IsLocked() bool {\n    s.mu.Lock()\n    defer s.mu.Unlock()\n    return s.lockID != \"\"\n}","tryCatchPattern":null,"preventionTips":["Capture the lock id from Lock and only Unlock when it is non-empty.","In cleanup paths, guard Unlock with the id check.","Clear your local id reference after Unlock to prevent double-unlock.","Never Unlock from a goroutine that did not perform the Lock."],"tags":["state","locking","concurrency","local-backend","programming-error"],"analyzedSha":"c9def3e214014c1188faabfc4a5bde5095139765","analyzedAt":"2026-08-07T15:39:49.278Z","schemaVersion":2},"datasetVersion":"2026-08-07T20:17:04.800Z"}