Skip to content

Commit effcdcd

Browse files
zylaclaude
andcommitted
Simplify locking: use single EXCLUSIVE lock for credentials
Replace mixed SHARED/EXCLUSIVE locking approach with a single EXCLUSIVE lock for all credential operations. This eliminates the need for double-check locking and simplifies the code significantly. Changes: - Remove readCredentialsCache and writeCredentialsCache functions - Add loadOrRefreshClient that acquires EXCLUSIVE lock for entire operation - Add tryReadCache helper (no locking, caller holds lock) - Add refreshToken helper (creates token and writes cache under lock) - Refactor initClient → createTokenFromGitHub (just creates token) - Fast path in getClient checks IORef without locks Benefits: - No double-check locking needed - Prevents thundering herd (only one process refreshes at a time) - No torn reads (all file operations under EXCLUSIVE lock) - Simpler code with clear locking boundaries - Lock duration is brief (~100ms for HTTP call) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent f0349a9 commit effcdcd

File tree

1 file changed

+95
-105
lines changed

1 file changed

+95
-105
lines changed

src/CommitStatus.hs

Lines changed: 95 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -56,66 +56,86 @@ data CredentialsCache = CredentialsCache
5656
credentialsCacheFile :: Settings -> FilePath
5757
credentialsCacheFile settings = settings.stateDirectory <> "/.github-token-cache.json"
5858

59-
readCredentialsCache :: Settings -> IO (Maybe (T.Text, UTCTime))
60-
readCredentialsCache settings = do
61-
let cacheFile = credentialsCacheFile settings
59+
-- Try to read cache file (no locking - caller should hold lock)
60+
tryReadCache :: FilePath -> IO (Maybe (T.Text, UTCTime))
61+
tryReadCache cacheFile = do
6262
exists <- doesFileExist cacheFile
6363
if exists then do
6464
result <- eitherDecodeFileStrict @CredentialsCache cacheFile
6565
case result of
6666
Left _ -> pure Nothing
67-
Right cache -> do
67+
Right cache ->
6868
case iso8601ParseM (toString cache.cachedExpiresAt) of
6969
Just expiresAt -> pure $ Just (cache.cachedToken, expiresAt)
7070
Nothing -> pure Nothing
7171
else
7272
pure Nothing
7373

74-
writeCredentialsCache :: Settings -> T.Text -> UTCTime -> IO ()
75-
writeCredentialsCache settings token expiresAt = do
76-
let cacheFile = credentialsCacheFile settings
77-
let lockFile = cacheFile <> ".lock"
78-
let cache = CredentialsCache
79-
{ cachedToken = token
80-
, cachedExpiresAt = T.pack $ iso8601Show expiresAt
81-
}
82-
withFileLock lockFile Exclusive \_ ->
83-
BL.writeFile cacheFile (encode cache)
84-
8574
getClient :: AppState -> IO GithubClient
8675
getClient appState = do
8776
mClient <- readIORef appState.githubClient
8877
case mClient of
8978
Just client -> do
90-
-- Check if token is expired or expiring soon (within 5 minutes)
79+
-- Fast path: check if cached token is still valid
9180
now <- getCurrentTime
92-
let secondsUntilExpiry = diffUTCTime client.expiresAt now
93-
if secondsUntilExpiry < 300 then do
94-
logDebug appState $ "GitHub token expired or expiring soon (in " <> show (floor secondsUntilExpiry :: Int) <> "s), refreshing..."
95-
writeIORef appState.githubClient Nothing
96-
getClient appState
97-
else
98-
pure client
99-
Nothing -> do
100-
-- Try reading from cache file first (for child processes)
101-
mCached <- readCredentialsCache appState.settings
102-
case mCached of
103-
Just (cachedToken, expiresAt) -> do
104-
now <- getCurrentTime
105-
let secondsUntilExpiry = diffUTCTime expiresAt now
106-
if secondsUntilExpiry < 300 then do
107-
-- Cached token is expired, create new one
108-
logDebug appState "Cached GitHub token expired, creating new one"
109-
initClient appState
110-
else do
111-
-- Use cached token, build client
81+
if diffUTCTime client.expiresAt now >= 300
82+
then pure client
83+
else do
84+
-- Token expiring, need to refresh
85+
logDebug appState $ "GitHub token expired or expiring soon (in " <> show (floor (diffUTCTime client.expiresAt now) :: Int) <> "s), refreshing..."
86+
writeIORef appState.githubClient Nothing
87+
loadOrRefreshClient appState
88+
Nothing ->
89+
loadOrRefreshClient appState
90+
91+
loadOrRefreshClient :: AppState -> IO GithubClient
92+
loadOrRefreshClient appState = do
93+
let cacheFile = credentialsCacheFile appState.settings
94+
let lockFile = cacheFile <> ".lock"
95+
96+
client <- withFileLock lockFile Exclusive \_ -> do
97+
-- Under EXCLUSIVE lock: read, check, refresh if needed
98+
mCached <- tryReadCache cacheFile
99+
100+
now <- getCurrentTime
101+
case mCached of
102+
Just (cachedToken, expiresAt)
103+
| diffUTCTime expiresAt now >= 300 -> do
104+
-- Valid cached token
112105
logDebug appState "Using cached GitHub token from file"
113-
client <- buildClientWithToken appState cachedToken expiresAt
114-
writeIORef appState.githubClient (Just client)
115-
pure client
116-
Nothing -> do
117-
-- No cache, create new token
118-
initClient appState
106+
buildClientWithToken appState cachedToken expiresAt
107+
| otherwise -> do
108+
-- Expired token, refresh
109+
logDebug appState "Cached token expired, refreshing"
110+
refreshToken appState cacheFile
111+
Nothing -> do
112+
-- No cache, create new token
113+
logDebug appState "No cached token, creating new one"
114+
refreshToken appState cacheFile
115+
116+
writeIORef appState.githubClient (Just client)
117+
pure client
118+
119+
-- Create new token and write to cache (caller should hold EXCLUSIVE lock)
120+
refreshToken :: AppState -> FilePath -> IO GithubClient
121+
refreshToken appState cacheFile = do
122+
tokenResponse <- createTokenFromGitHub appState
123+
124+
expiresAt <- case iso8601ParseM (toString tokenResponse.expires_at) of
125+
Just t -> pure t
126+
Nothing -> do
127+
logError appState $ "CommitStatus: Failed to parse expires_at: " <> tokenResponse.expires_at
128+
exitFailure
129+
130+
-- Write to cache (already under EXCLUSIVE lock, no additional locking needed)
131+
let cache = CredentialsCache
132+
{ cachedToken = tokenResponse.token
133+
, cachedExpiresAt = T.pack $ iso8601Show expiresAt
134+
}
135+
BL.writeFile cacheFile (encode cache)
136+
137+
-- Build and return client
138+
buildClientWithToken appState tokenResponse.token expiresAt
119139

120140
buildClientWithToken :: AppState -> T.Text -> UTCTime -> IO GithubClient
121141
buildClientWithToken _appState accessToken expiresAt = do
@@ -140,80 +160,50 @@ buildClientWithToken _appState accessToken expiresAt = do
140160
, expiresAt = expiresAt
141161
}
142162

143-
initClient :: AppState -> IO GithubClient
144-
initClient appState = do
163+
-- Create a new GitHub App installation token from GitHub API
164+
createTokenFromGitHub :: AppState -> IO InstallationTokenResponse
165+
createTokenFromGitHub appState = do
145166
-- Load environment variables
146167
apiUrl <- fromMaybe "https://api.github.com" <$> lookupEnv "GITHUB_API_URL"
147168
appId <- getEnv "GITHUB_APP_ID"
148169
installationId <- getEnv "GITHUB_INSTALLATION_ID"
149170
privateKeyStr <- getEnv "GITHUB_APP_PRIVATE_KEY"
150-
owner <- getEnv "GITHUB_REPOSITORY_OWNER"
151-
repo <- getEnv "GITHUB_REPOSITORY"
171+
152172
-- Prepare the HTTP manager
153173
manager <- HTTP.newManager tlsManagerSettings
154174

155-
let createToken = do
156-
let privateKeyBytes = encodeUtf8 $ Text.replace "|" "\n" $ toText privateKeyStr
157-
let privateKey = fromMaybe (error "Invalid github key") $ readRsaSecret privateKeyBytes
158-
159-
-- Create the JWT token
160-
now <- getPOSIXTime
161-
let claims = mempty { iss = stringOrURI $ T.pack appId
162-
, iat = numericDate now
163-
, exp = numericDate (now + 5 * 60)
164-
}
165-
let jwt = encodeSigned (EncodeRSAPrivateKey privateKey) (mempty { alg = Just RS256 }) claims
166-
167-
-- Get the installation access token
168-
let installUrl = apiUrl <> "/app/installations/" ++ installationId ++ "/access_tokens"
169-
initRequest <- HTTP.parseRequest installUrl
170-
let request = initRequest
171-
{ HTTP.method = "POST"
172-
, HTTP.requestHeaders =
173-
[ ("Authorization", "Bearer " <> TE.encodeUtf8 jwt)
174-
, ("Accept", "application/vnd.github.v3+json")
175-
, ("User-Agent", "restaumatic-bot")
176-
]
177-
}
178-
response <- HTTP.httpLbs request manager
179-
let mTokenResponse = eitherDecode @InstallationTokenResponse (HTTP.responseBody response)
180-
case mTokenResponse of
181-
Left err -> do
182-
logError appState $ "CommitStatus: Failed to parse installation token response: " <> show err
183-
logError appState $ "CommitStatus: Response: " <> decodeUtf8 response.responseBody
184-
185-
-- FIXME: handle the error better
186-
exitFailure
187-
Right tokenResponse ->
188-
pure tokenResponse
175+
let privateKeyBytes = encodeUtf8 $ Text.replace "|" "\n" $ toText privateKeyStr
176+
let privateKey = fromMaybe (error "Invalid github key") $ readRsaSecret privateKeyBytes
189177

190-
-- Create new token
191-
tokenResponse <- createToken
178+
-- Create the JWT token
179+
now <- getPOSIXTime
180+
let claims = mempty { iss = stringOrURI $ T.pack appId
181+
, iat = numericDate now
182+
, exp = numericDate (now + 5 * 60)
183+
}
184+
let jwt = encodeSigned (EncodeRSAPrivateKey privateKey) (mempty { alg = Just RS256 }) claims
192185

193-
-- Parse expires_at to UTCTime
194-
expiresAt <- case iso8601ParseM (toString tokenResponse.expires_at) of
195-
Just t -> pure t
196-
Nothing -> do
197-
logError appState $ "CommitStatus: Failed to parse expires_at: " <> tokenResponse.expires_at
186+
-- Get the installation access token
187+
let installUrl = apiUrl <> "/app/installations/" ++ installationId ++ "/access_tokens"
188+
initRequest <- HTTP.parseRequest installUrl
189+
let request = initRequest
190+
{ HTTP.method = "POST"
191+
, HTTP.requestHeaders =
192+
[ ("Authorization", "Bearer " <> TE.encodeUtf8 jwt)
193+
, ("Accept", "application/vnd.github.v3+json")
194+
, ("User-Agent", "restaumatic-bot")
195+
]
196+
}
197+
response <- HTTP.httpLbs request manager
198+
let mTokenResponse = eitherDecode @InstallationTokenResponse (HTTP.responseBody response)
199+
case mTokenResponse of
200+
Left err -> do
201+
logError appState $ "CommitStatus: Failed to parse installation token response: " <> show err
202+
logError appState $ "CommitStatus: Response: " <> decodeUtf8 response.responseBody
203+
-- FIXME: handle the error better
198204
exitFailure
199-
200-
-- Write to cache file for child processes
201-
writeCredentialsCache appState.settings tokenResponse.token expiresAt
202-
203-
let client = GithubClient
204-
{ apiUrl = T.pack apiUrl
205-
, appId = T.pack appId
206-
, installationId = T.pack installationId
207-
, privateKey = T.pack privateKeyStr
208-
, owner = T.pack owner
209-
, repo = T.pack repo
210-
, manager = manager
211-
, accessToken = tokenResponse.token
212-
, expiresAt = expiresAt
213-
}
214-
215-
writeIORef appState.githubClient (Just client)
216-
pure client
205+
Right tokenResponse ->
206+
pure tokenResponse
217207

218208
updateCommitStatus :: MonadIO m => AppState -> StatusRequest -> m ()
219209
updateCommitStatus appState statusRequest = liftIO do

0 commit comments

Comments
 (0)