Skip to content

Commit 2c866e4

Browse files
authored
Attempt to recover from corrupted updates.json file (#1505)
Signed-off-by: lujunsan <[email protected]>
1 parent 183844a commit 2c866e4

File tree

2 files changed

+112
-2
lines changed

2 files changed

+112
-2
lines changed

pkg/updates/checker.go

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import (
55
"encoding/json"
66
"fmt"
77
"os"
8+
"regexp"
89
"strings"
910
"time"
1011

1112
"github.com/adrg/xdg"
13+
"github.com/gofrs/flock"
1214
"github.com/google/uuid"
1315
"golang.org/x/mod/semver"
1416

@@ -47,7 +49,13 @@ func NewUpdateChecker(versionClient VersionClient) (UpdateChecker, error) {
4749
var contents updateFile
4850
err = json.Unmarshal(rawContents, &contents)
4951
if err != nil {
50-
return nil, fmt.Errorf("failed to deserialize update file: %w", err)
52+
// If the file is corrupted, attempt to recover
53+
if recoveredFile, recoverErr := recoverCorruptedJSON(rawContents); recoverErr == nil {
54+
contents = recoveredFile
55+
// Note: Update file is corrupted, attempting to preserve instance ID
56+
} else {
57+
return nil, fmt.Errorf("failed to deserialize update file: %w", err)
58+
}
5159
}
5260
instanceID = contents.InstanceID
5361
previousVersion = contents.LatestVersion
@@ -106,7 +114,13 @@ func (d *defaultUpdateChecker) CheckLatestVersion() error {
106114
}
107115
} else {
108116
if err := json.Unmarshal(rawContents, &currentFile); err != nil {
109-
return fmt.Errorf("failed to deserialize update file: %w", err)
117+
// If the file is corrupted, attempt to recover
118+
if recoveredFile, recoverErr := recoverCorruptedJSON(rawContents); recoverErr == nil {
119+
currentFile = recoveredFile
120+
// Note: Recovered corrupted update file, preserving instance ID
121+
} else {
122+
return fmt.Errorf("failed to deserialize update file: %w", err)
123+
}
110124
}
111125

112126
// Initialize components map if it doesn't exist (for backward compatibility)
@@ -151,6 +165,15 @@ func (d *defaultUpdateChecker) CheckLatestVersion() error {
151165
return fmt.Errorf("failed to marshal updated data: %w", err)
152166
}
153167

168+
// Acquire lock just before writing to minimize lock time
169+
lockFile := flock.New(d.updateFilePath + ".lock")
170+
if err := lockFile.Lock(); err != nil {
171+
return fmt.Errorf("failed to acquire lock on update file: %w", err)
172+
}
173+
defer func() {
174+
_ = lockFile.Unlock()
175+
}()
176+
154177
if err := os.WriteFile(d.updateFilePath, updatedData, 0600); err != nil {
155178
return fmt.Errorf("failed to write updated file: %w", err)
156179
}
@@ -181,3 +204,20 @@ func notifyIfUpdateAvailable(current, latest string) {
181204
fmt.Fprintf(os.Stderr, "A new version of ToolHive is available: %s\nCurrently running: %s\n", latest, current)
182205
}
183206
}
207+
208+
// recoverCorruptedJSON attempts to recover from common JSON corruption issues
209+
// while preserving the instance_id to avoid regenerating it.
210+
func recoverCorruptedJSON(rawContents []byte) (updateFile, error) {
211+
content := string(rawContents)
212+
213+
// Extract the instance_id from the corrupted JSON and regenerate the file
214+
instanceIDRegex := regexp.MustCompile(`"instance_id":"([^"]+)"`)
215+
if matches := instanceIDRegex.FindStringSubmatch(content); len(matches) > 1 {
216+
return updateFile{
217+
InstanceID: matches[1],
218+
Components: make(map[string]componentInfo),
219+
}, nil
220+
}
221+
222+
return updateFile{}, fmt.Errorf("unable to recover corrupted JSON")
223+
}

pkg/updates/checker_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,3 +330,73 @@ func TestNotifyIfUpdateAvailable(t *testing.T) {
330330
notifyIfUpdateAvailable(currentVersion, latestVersion)
331331
})
332332
}
333+
334+
// TestCorruptedJSONRecovery tests the recovery of corrupted update files
335+
func TestCorruptedJSONRecovery(t *testing.T) {
336+
t.Parallel()
337+
338+
t.Run("recover from corrupted JSON with extra braces", func(t *testing.T) {
339+
t.Parallel()
340+
341+
// Create corrupted JSON with extra closing braces (real example from user)
342+
corruptedJSON := `{"instance_id":"test-instance-recovery","latest_version":"v0.2.3","components":{"API":{"last_check":"2025-08-01T11:12:00.740318Z"},"CLI":{"last_check":"2025-07-01T10:54:28.356601Z"},"UI":{"last_check":"2025-08-05T13:52:11.49587Z"}}}}}`
343+
344+
// Test recovery function directly
345+
recovered, err := recoverCorruptedJSON([]byte(corruptedJSON))
346+
347+
// Verify recovery succeeded
348+
require.NoError(t, err)
349+
assert.Equal(t, "test-instance-recovery", recovered.InstanceID)
350+
assert.NotNil(t, recovered.Components)
351+
assert.Empty(t, recovered.LatestVersion) // Should be empty in fresh recovery
352+
})
353+
354+
t.Run("recover from corrupted JSON in NewUpdateChecker", func(t *testing.T) {
355+
t.Parallel()
356+
357+
// Setup corrupted file
358+
tempDir, err := os.MkdirTemp("", "toolhive-corruption-test-*")
359+
require.NoError(t, err)
360+
defer os.RemoveAll(tempDir)
361+
362+
corruptedFilePath := filepath.Join(tempDir, "updates.json")
363+
corruptedJSON := `{"instance_id":"test-instance-recovery","latest_version":"v0.2.3","components":{"CLI":{"last_check":"2025-08-20T09:47:11.528773Z"}}}}}`
364+
365+
err = os.WriteFile(corruptedFilePath, []byte(corruptedJSON), 0600)
366+
require.NoError(t, err)
367+
368+
// Create mock client
369+
mockClient := setupMockVersionClient(t)
370+
mockClient.On("GetComponent").Return("CLI")
371+
mockClient.On("GetLatestVersion", "test-instance-recovery", testCurrentVersion).Return(testLatestVersion, nil)
372+
373+
// Create update checker - this should recover from corruption during initialization
374+
checker := &defaultUpdateChecker{
375+
currentVersion: testCurrentVersion,
376+
updateFilePath: corruptedFilePath,
377+
versionClient: mockClient,
378+
previousAPIResponse: "",
379+
component: "CLI",
380+
}
381+
382+
// This should work without error despite corrupted file
383+
err = checker.CheckLatestVersion()
384+
385+
// Should not fail due to JSON corruption
386+
assert.NoError(t, err)
387+
})
388+
389+
t.Run("recovery fails when instance_id cannot be extracted", func(t *testing.T) {
390+
t.Parallel()
391+
392+
// Create completely invalid JSON without instance_id
393+
invalidJSON := `{"invalid":"json","no_instance_id":true}}`
394+
395+
// Test recovery function directly
396+
_, err := recoverCorruptedJSON([]byte(invalidJSON))
397+
398+
// Should fail to recover
399+
require.Error(t, err)
400+
assert.Contains(t, err.Error(), "unable to recover corrupted JSON")
401+
})
402+
}

0 commit comments

Comments
 (0)