From c28e8209401c6e11be2e15b3c7dcfc8cea6d5b76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:22:41 +0000 Subject: [PATCH 1/2] Initial plan From 7cf0397dd0379d8811a4878a9d272a4525482472 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:33:42 +0000 Subject: [PATCH 2/2] Add corrupt preference recovery Co-authored-by: jdneo <6193897+jdneo@users.noreply.github.com> --- .../chat/services/PreferenceStorageTest.java | 149 ++++++++++- .../services/UserPreferenceServiceTest.java | 132 ++++++++++ .../copilot/eclipse/ui/chat/Messages.java | 4 + .../eclipse/ui/chat/PreferenceStatus.java | 33 ++- .../eclipse/ui/chat/messages.properties | 4 + .../ui/chat/services/PreferenceStorage.java | 243 +++++++++++++++++- 6 files changed, 553 insertions(+), 12 deletions(-) diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/PreferenceStorageTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/PreferenceStorageTest.java index de41c3e8..23594d57 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/PreferenceStorageTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/PreferenceStorageTest.java @@ -11,6 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -20,10 +21,12 @@ import static org.mockito.Mockito.when; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.AccessDeniedException; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.util.Arrays; import java.util.List; import java.util.Queue; import java.util.concurrent.CompletableFuture; @@ -38,6 +41,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; @@ -62,6 +66,8 @@ class PreferenceStorageTest { private final Queue deadlines = new ConcurrentLinkedQueue<>(); private final TestRealm realm = new TestRealm(); private final AtomicLong clock = new AtomicLong(); + @TempDir + private Path directory; private CompletableFuture response; private PreferenceStorage storage; @@ -71,6 +77,10 @@ void setUp() { when(auth.getUserName()).thenReturn("alice"); response = new CompletableFuture<>(); when(connection.persistence()).thenAnswer(invocation -> response); + when(files.readContents(any(Path.class))).thenAnswer(invocation -> { + String text = files.read(invocation.getArgument(0)); + return new PreferenceStorage.FileContents(text.getBytes(StandardCharsets.UTF_8), text); + }); Mockito.doAnswer(invocation -> { work.add(invocation.getArgument(0)); return null; @@ -134,6 +144,7 @@ void testInitialize_UnreadableFile_FailsAndDoesNotOverwrite() throws Exception { load(); assertFailedWithoutWrites(); + assertEquals(State.FAILED, storage.getState()); storage.initialize(); drainWork(); verify(connection).persistence(); @@ -148,6 +159,137 @@ void testInitialize_InvalidJson_FailsAndDoesNotOverwrite(String content) throws load(); assertFailedWithoutWrites(); + assertEquals(State.CORRUPT, storage.getState()); + } + + @Test + void testRestoreDefaults_ExplicitRequestBacksUpExactBytesAndPublishesDefaults() throws Exception { + Path path = directory.resolve("alice").resolve("pref.json"); + Files.createDirectories(path.getParent()); + byte[] original = "{\"chatModel\":\"unfinished".getBytes(StandardCharsets.UTF_8); + Files.write(path, original); + when(files.read(path)).thenReturn(new String(original, StandardCharsets.UTF_8)); + Mockito.doCallRealMethod().when(files).restore(eq(path), any(), anyString(), any()); + load(); + + assertEquals(State.CORRUPT, storage.getState()); + verify(files, never()).restore(any(), any(), anyString(), any()); + storage.restoreDefaults(); + storage.restoreDefaults(); + storage.retry(); + storage.initialize(); + assertEquals(State.RESTORING, storage.getState()); + drainWork(); + realm.drain(); + + assertEquals(State.READY, storage.getState()); + assertNull(storage.getReadyPreferences().getChatModel()); + assertNull(storage.getReadyPreferences().getUserInputs()); + verify(files).restore(eq(path), any(), anyString(), any()); + try (var filesOnDisk = Files.list(path.getParent())) { + Path backup = filesOnDisk.filter(candidate -> candidate.getFileName().toString().endsWith(".bak")) + .findFirst().orElseThrow(); + assertTrue(Arrays.equals(original, Files.readAllBytes(backup))); + } + assertTrue(Files.readString(path).contains("\"chatModel\":null")); + } + + @Test + void testRestoreDefaults_FailurePreservesOriginalAndCanBeRetried() throws Exception { + Path path = directory.resolve("alice").resolve("pref.json"); + Files.createDirectories(path.getParent()); + byte[] original = "{invalid".getBytes(StandardCharsets.UTF_8); + Files.write(path, original); + when(files.read(path)).thenReturn(new String(original, StandardCharsets.UTF_8)); + Mockito.doThrow(new IOException("backup failed")).when(files).restore(eq(path), any(), anyString(), any()); + load(); + + storage.restoreDefaults(); + drainWork(); + realm.drain(); + + assertEquals(State.RESTORE_FAILED, storage.getState()); + assertTrue(Arrays.equals(original, Files.readAllBytes(path))); + verify(files, never()).write(any(), any()); + + Mockito.doAnswer(invocation -> PreferenceStorage.restoreFile(invocation.getArgument(0), + invocation.getArgument(1), invocation.getArgument(2), invocation.getArgument(3))) + .when(files).restore(eq(path), any(), anyString(), any()); + storage.restoreDefaults(); + drainWork(); + realm.drain(); + + assertEquals(State.READY, storage.getState()); + try (var filesOnDisk = Files.list(path.getParent())) { + Path backup = filesOnDisk.filter(candidate -> candidate.getFileName().toString().endsWith(".bak")) + .findFirst().orElseThrow(); + assertTrue(Arrays.equals(original, Files.readAllBytes(backup))); + } + } + + @Test + void testRestoreDefaults_ChangedFileIsReloadedInsteadOfReplaced() throws Exception { + Path path = directory.resolve("alice").resolve("pref.json"); + Files.createDirectories(path.getParent()); + Files.writeString(path, "{invalid"); + when(files.read(path)).thenReturn("{invalid"); + Mockito.doCallRealMethod().when(files).restore(eq(path), any(), anyString(), any()); + Mockito.doCallRealMethod().when(files).restore(eq(path), any(), anyString(), any()); + load(); + + Files.writeString(path, "{\"chatModel\":\"new-choice\"}"); + when(files.read(path)).thenReturn("{\"chatModel\":\"new-choice\"}"); + response = new CompletableFuture<>(); + storage.restoreDefaults(); + drainWork(); + response.complete(persistence()); + drainWork(); + realm.drain(); + + assertEquals(State.READY, storage.getState()); + assertEquals("new-choice", storage.getReadyPreferences().getChatModel()); + assertEquals("{\"chatModel\":\"new-choice\"}", Files.readString(path)); + try (var filesOnDisk = Files.list(path.getParent())) { + assertTrue(filesOnDisk.noneMatch(candidate -> candidate.getFileName().toString().endsWith(".bak"))); + } + } + + @Test + void testRestoreDefaults_AccountChangeBeforeWorkerStarts_DoesNotWriteOldAccount() throws Exception { + Path path = directory.resolve("alice").resolve("pref.json"); + Files.createDirectories(path.getParent()); + byte[] original = "{invalid".getBytes(StandardCharsets.UTF_8); + Files.write(path, original); + when(files.read(path)).thenReturn(new String(original, StandardCharsets.UTF_8)); + load(); + + storage.restoreDefaults(); + when(auth.getUserName()).thenReturn("bob"); + assertEquals(State.UNAVAILABLE, storage.getState()); + drainWork(); + realm.drain(); + + assertTrue(Arrays.equals(original, Files.readAllBytes(path))); + verify(files, never()).restore(any(), any(), anyString(), any()); + assertEquals(State.UNAVAILABLE, storage.getReadiness().getValue()); + } + + @Test + void testRestoreDefaults_DisposalBeforeWorkerStartsDoesNotWrite() throws Exception { + Path path = directory.resolve("alice").resolve("pref.json"); + Files.createDirectories(path.getParent()); + byte[] original = "{invalid".getBytes(StandardCharsets.UTF_8); + Files.write(path, original); + when(files.read(path)).thenReturn(new String(original, StandardCharsets.UTF_8)); + load(); + + storage.restoreDefaults(); + storage.dispose(); + drainWork(); + + assertTrue(Arrays.equals(original, Files.readAllBytes(path))); + verify(files, never()).restore(any(), any(), anyString(), any()); + assertEquals(State.DISPOSED, storage.getState()); } @Test @@ -174,6 +316,7 @@ void testInitialize_LegacyGsonExtensions_FailWithoutOverwriting(String content) load(); assertFailedWithoutWrites(); + assertEquals(State.CORRUPT, storage.getState()); } @Test @@ -706,8 +849,8 @@ private void load() { } private void assertFailedWithoutWrites() throws IOException { - assertEquals(State.FAILED, storage.getState()); - assertEquals(State.FAILED, storage.getReadiness().getValue()); + assertTrue(storage.getState() == State.FAILED || storage.getState() == State.CORRUPT); + assertEquals(storage.getState(), storage.getReadiness().getValue()); assertNull(storage.getReadyPreferences()); storage.persist(); verify(files, never()).write(any(), any()); @@ -715,7 +858,7 @@ private void assertFailedWithoutWrites() throws IOException { private ChatPersistence persistence() { ChatPersistence result = new ChatPersistence(); - result.setPath(Path.of("preferences").toAbsolutePath().toString()); + result.setPath(directory.toString()); return result; } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceServiceTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceServiceTest.java index ffc8eb6e..5ffd7b93 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceServiceTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceServiceTest.java @@ -19,10 +19,12 @@ import static org.mockito.ArgumentMatchers.eq; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -492,6 +494,130 @@ public void write(Path path, String content) throws IOException { }); } + @Test + void testCorruptPreferences_RestoreDefaultsBacksUpAndRefreshesConsumers() throws Exception { + byte[] original = "{\"chatModel\":".getBytes(StandardCharsets.UTF_8); + Path preferenceFile = directory.resolve("user").resolve("pref.json"); + Files.createDirectories(preferenceFile.getParent()); + Files.write(preferenceFile, original); + startAuthenticated(CompletableFuture.completedFuture(persistence())); + awaitUi(() -> storage.getReadiness().getValue() == PreferenceStorage.State.CORRUPT); + + runOnUi(() -> { + assertEquals(Messages.preferenceCorrupt, statusMessage().getText()); + Link restore = Arrays.stream(status.getChildren()).filter(Link.class::isInstance).map(Link.class::cast) + .filter(link -> "preference-restore-defaults".equals( + link.getData("org.eclipse.swtbot.widget.key"))) + .findFirst().orElseThrow(); + assertTrue(restore.getEnabled()); + assertTrue(Files.exists(preferenceFile)); + restore.notifyListeners(SWT.Selection, new org.eclipse.swt.widgets.Event()); + }); + awaitUi(() -> storage.getReadiness().getValue() == PreferenceStorage.State.READY + && "Agent".equals(service.getActiveModeNameOrId())); + + assertTrue(Arrays.equals(original, Files.readAllBytes(findBackup(preferenceFile)))); + assertNull(storage.getReadyPreferences().getChatModel()); + assertNull(storage.getReadyPreferences().getUserInputs()); + runOnUi(() -> assertEquals("", service.getPreviousInput(""))); + + PreferenceStorage fresh = new PreferenceStorage(connection, auth); + try { + fresh.initialize(); + awaitUi(() -> fresh.getReadiness().getValue() == PreferenceStorage.State.READY); + assertNull(fresh.getReadyPreferences().getChatModel()); + } finally { + fresh.dispose(); + } + } + + @Test + void testCorruptPreferences_ModeRetryRemainsIndependent() throws Exception { + CompletableFuture modes = new CompletableFuture<>(); + when(connection.listConversationModes(any())).thenReturn(modes); + writePreferences("{invalid"); + startAuthenticated(CompletableFuture.completedFuture(persistence())); + verify(connection, timeout(5000)).listConversationModes(any()); + modes.completeExceptionally(new IOException("mode discovery failed")); + awaitUi(() -> storage.getReadiness().getValue() == PreferenceStorage.State.CORRUPT + && service.getModeDiscoveryState() == UserPreferenceService.ModeDiscoveryState.FAILED); + + when(connection.listConversationModes(any())).thenReturn(CompletableFuture.completedFuture( + new ConversationMode[] {builtInMode("Ask", "Ask")})); + runOnUi(() -> { + Link retry = Arrays.stream(status.getChildren()).filter(Link.class::isInstance).map(Link.class::cast) + .filter(link -> "preference-retry".equals(link.getData("org.eclipse.swtbot.widget.key"))) + .findFirst().orElseThrow(); + assertTrue(retry.getEnabled()); + retry.notifyListeners(SWT.Selection, new org.eclipse.swt.widgets.Event()); + }); + awaitUi(() -> service.getModeDiscoveryState() == UserPreferenceService.ModeDiscoveryState.READY); + + assertEquals(PreferenceStorage.State.CORRUPT, storage.getState()); + verify(connection).persistence(); + } + + @Test + void testRestoreDefaults_FileWorkRunsInBackgroundWhileSwtProcessesEvents() throws Exception { + when(auth.isSignedIn()).thenReturn(true); + when(auth.getUserName()).thenReturn("user"); + when(connection.persistence()).thenReturn(CompletableFuture.completedFuture(persistence())); + Path preferenceFile = writePreferences("{invalid"); + CountDownLatch restoreStarted = new CountDownLatch(1); + CountDownLatch continueRestore = new CountDownLatch(1); + AtomicBoolean ranOnUiThread = new AtomicBoolean(); + runOnUi(() -> { + storage = new PreferenceStorage(connection, auth, DisplayRealm.getRealm(Display.getDefault()), + Executors.newSingleThreadExecutor(), Executors.newSingleThreadScheduledExecutor(), System::nanoTime, + new PreferenceStorage.FileAccess() { + @Override + public String read(Path path) throws IOException { + return Files.readString(path); + } + + @Override + public void write(Path path, String content) throws IOException { + Files.writeString(path, content); + } + + @Override + public boolean restore(Path path, PreferenceStorage.FileContents original, String content, + java.util.function.BooleanSupplier isCurrent) throws IOException { + ranOnUiThread.set(Display.getCurrent() != null); + restoreStarted.countDown(); + try { + if (!continueRestore.await(5, TimeUnit.SECONDS)) { + throw new IOException("Timed out waiting to continue recovery"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IOException(exception); + } + return PreferenceStorage.restoreFile(path, original, content, isCurrent); + } + }); + createControls(); + }); + try { + awaitUi(() -> storage.getReadiness().getValue() == PreferenceStorage.State.CORRUPT); + runOnUi(() -> Arrays.stream(status.getChildren()).filter(Link.class::isInstance).map(Link.class::cast) + .filter(link -> "preference-restore-defaults".equals(link.getData("org.eclipse.swtbot.widget.key"))) + .findFirst().orElseThrow().notifyListeners(SWT.Selection, new org.eclipse.swt.widgets.Event())); + assertTrue(restoreStarted.await(5, TimeUnit.SECONDS)); + + CompletableFuture uiProcessed = new CompletableFuture<>(); + Display.getDefault().asyncExec(() -> uiProcessed.complete(!shell.isDisposed())); + assertTrue(uiProcessed.get(5, TimeUnit.SECONDS)); + assertFalse(ranOnUiThread.get()); + when(auth.getUserName()).thenReturn("other-user"); + assertEquals(PreferenceStorage.State.UNAVAILABLE, storage.getState()); + } finally { + continueRestore.countDown(); + } + awaitUi(() -> storage.getReadiness().getValue() == PreferenceStorage.State.UNAVAILABLE); + assertEquals("{invalid", Files.readString(preferenceFile)); + } + private void startAuthenticated(CompletableFuture rpc) { when(auth.isSignedIn()).thenReturn(true); when(auth.getUserName()).thenReturn("user"); @@ -537,6 +663,12 @@ private Path writePreferences(String content) throws Exception { return file; } + private Path findBackup(Path preferenceFile) throws IOException { + try (var files = Files.list(preferenceFile.getParent())) { + return files.filter(path -> path.getFileName().toString().endsWith(".bak")).findFirst().orElseThrow(); + } + } + private static void runOnUi(Runnable action) { AtomicReference failure = new AtomicReference<>(); Display.getDefault().syncExec(() -> { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java index 9e911649..87c4e0b7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java @@ -23,8 +23,12 @@ public final class Messages extends NLS { public static String configureModes; public static String preferenceLoading; public static String preferenceLoadFailed; + public static String preferenceCorrupt; + public static String preferenceRestoringDefaults; + public static String preferenceRestoreFailed; public static String preferenceUnavailable; public static String preferenceRetry; + public static String preferenceRestoreDefaults; public static String modeDiscoveryLoading; public static String modeDiscoveryFailed; public static String agentMessageWidget_openInBrowserButton; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/PreferenceStatus.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/PreferenceStatus.java index 7e276642..8befc6ab 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/PreferenceStatus.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/PreferenceStatus.java @@ -22,7 +22,7 @@ */ public class PreferenceStatus extends Composite { /** - * Creates a status message and an explicit retry action. + * Creates a status message and an explicit recovery action. * * @param parent parent control * @param storage shared chat preference storage @@ -32,7 +32,7 @@ public PreferenceStatus(Composite parent, PreferenceStorage storage) { } /** - * Creates status and retry controls for preference loading and independent mode discovery. + * Creates status and recovery/retry controls for preferences and independent mode discovery. * * @param parent parent control * @param storage shared chat preference storage @@ -40,7 +40,7 @@ public PreferenceStatus(Composite parent, PreferenceStorage storage) { */ public PreferenceStatus(Composite parent, PreferenceStorage storage, UserPreferenceService preferences) { super(parent, SWT.NONE); - setLayout(new GridLayout(2, false)); + setLayout(new GridLayout(3, false)); GridData data = new GridData(SWT.FILL, SWT.CENTER, true, false); setLayoutData(data); Label message = new Label(this, SWT.WRAP); @@ -52,12 +52,21 @@ public PreferenceStatus(Composite parent, PreferenceStorage storage, UserPrefere GridData retryData = new GridData(SWT.RIGHT, SWT.CENTER, false, false); retry.setLayoutData(retryData); retry.addListener(SWT.Selection, event -> { - if (storage.getState() == State.READY && preferences != null) { + State state = storage.getState(); + if (preferences != null && preferences.getModeDiscoveryState() != ModeDiscoveryState.READY + && preferences.getModeDiscoveryState() != ModeDiscoveryState.LOADING) { preferences.retryModeDiscovery(); - } else { + } + if (state == State.FAILED || state == State.UNAVAILABLE) { storage.retry(); } }); + Link restore = new Link(this, SWT.NONE); + restore.setText("" + Messages.preferenceRestoreDefaults + ""); + restore.setData("org.eclipse.swtbot.widget.key", "preference-restore-defaults"); + GridData restoreData = new GridData(SWT.RIGHT, SWT.CENTER, false, false); + restore.setLayoutData(restoreData); + restore.addListener(SWT.Selection, event -> storage.restoreDefaults()); Realm.runWithDefault(storage.getReadiness().getRealm(), () -> { ISideEffect effect = ISideEffect.create(() -> { return new Readiness(storage.getReadiness().getValue(), @@ -74,6 +83,9 @@ public PreferenceStatus(Composite parent, PreferenceStorage storage, UserPrefere String text = switch (state) { case LOADING -> Messages.preferenceLoading; case FAILED -> Messages.preferenceLoadFailed; + case CORRUPT -> Messages.preferenceCorrupt; + case RESTORING -> Messages.preferenceRestoringDefaults; + case RESTORE_FAILED -> Messages.preferenceRestoreFailed; default -> Messages.preferenceUnavailable; }; if (modePending) { @@ -81,11 +93,18 @@ public PreferenceStatus(Composite parent, PreferenceStorage storage, UserPrefere ? Messages.modeDiscoveryLoading : Messages.modeDiscoveryFailed; } message.setText(text); - boolean canRetry = state == State.FAILED || state == State.UNAVAILABLE - || (modePending && readiness.modes() != ModeDiscoveryState.LOADING); + boolean canRetry = (state == State.FAILED || state == State.UNAVAILABLE) + || (modePending && readiness.modes() != ModeDiscoveryState.LOADING) + || (state != State.READY && preferences != null + && (readiness.modes() == ModeDiscoveryState.FAILED + || readiness.modes() == ModeDiscoveryState.UNAVAILABLE)); + final boolean canRestore = state == State.CORRUPT || state == State.RESTORING || state == State.RESTORE_FAILED; retryData.exclude = !canRetry; retry.setVisible(canRetry); retry.setEnabled(canRetry); + restoreData.exclude = !canRestore; + restore.setVisible(canRestore); + restore.setEnabled(state != State.RESTORING); requestLayout(); }); addDisposeListener(event -> effect.dispose()); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties index 43aa47fa..3a316c1c 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties @@ -80,7 +80,11 @@ confirmation_autoApprovedDescription=Auto-approved from dialog # Chat preference loading preferenceLoading=Loading chat preferences... preferenceLoadFailed=Could not load chat preferences. Retry to restore your saved settings. +preferenceCorrupt=Saved chat preferences are damaged. Restore defaults to reset your saved chat settings and input history. +preferenceRestoringDefaults=Backing up damaged chat preferences and restoring defaults... +preferenceRestoreFailed=Could not restore defaults. Your saved preferences were not changed. Try again. preferenceUnavailable=Chat preferences are unavailable. preferenceRetry=Retry +preferenceRestoreDefaults=Restore defaults modeDiscoveryLoading=Loading chat modes... modeDiscoveryFailed=Could not load chat modes. Retry to use your selected mode. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/PreferenceStorage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/PreferenceStorage.java index 476d5ba3..fceb2e82 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/PreferenceStorage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/PreferenceStorage.java @@ -5,9 +5,16 @@ import java.io.IOException; import java.io.StringReader; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; @@ -16,6 +23,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; import java.util.function.LongSupplier; import com.google.gson.Gson; @@ -47,7 +55,7 @@ public class PreferenceStorage { * Availability of preferences for the current account. */ public enum State { - UNAVAILABLE, LOADING, READY, FAILED, DISPOSED + UNAVAILABLE, LOADING, READY, FAILED, CORRUPT, RESTORING, RESTORE_FAILED, DISPOSED } private final Object lock = new Object(); @@ -66,6 +74,8 @@ public enum State { private Attempt attempt; private UserPreference preferences; private Path preferencePath; + private Path failedPath; + private FileContents corruptContents; /** * Creates storage without starting RPC or file work. @@ -83,6 +93,19 @@ public String read(Path path) throws IOException { return Files.readString(path); } + @Override + public FileContents readContents(Path path) throws IOException { + byte[] bytes = Files.readAllBytes(path); + String text; + try { + text = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString(); + } catch (CharacterCodingException exception) { + text = null; + } + return new FileContents(bytes, text); + } + @Override public void write(Path path, String content) throws IOException { if (Files.notExists(path)) { @@ -90,6 +113,12 @@ public void write(Path path, String content) throws IOException { } Files.writeString(path, content); } + + @Override + public boolean restore(Path path, FileContents expected, String content, BooleanSupplier isCurrent) + throws IOException { + return restoreFile(path, expected, content, isCurrent); + } }); } @@ -142,6 +171,28 @@ public void retry() { start(true); } + /** + * Explicitly backs up confirmed corrupt preferences and replaces them with durable defaults. + */ + public void restoreDefaults() { + refreshAccount(); + Recovery recovery; + synchronized (lock) { + if ((state != State.CORRUPT && state != State.RESTORE_FAILED) || account == null || failedPath == null + || corruptContents == null) { + return; + } + state = State.RESTORING; + recovery = new Recovery(++generation, account, failedPath, corruptContents); + } + publishState(recovery.generation, State.RESTORING); + try { + worker.execute(() -> restore(recovery)); + } catch (RuntimeException exception) { + failRestore(recovery, exception); + } + } + /** * Returns the authoritative preference object only when ready for the current account. * @@ -245,6 +296,8 @@ private Attempt clear(State next) { attempt = null; preferences = null; preferencePath = null; + failedPath = null; + corruptContents = null; state = next; generation++; return previous; @@ -255,6 +308,7 @@ private void start(boolean retry) { Attempt next; synchronized (lock) { if (account == null || state == State.DISPOSED || state == State.LOADING || state == State.READY + || state == State.CORRUPT || state == State.RESTORING || state == State.RESTORE_FAILED || (!retry && state == State.FAILED)) { return; } @@ -338,10 +392,22 @@ private void read(Attempt pending, ChatPersistence result) { } Path path = directory.resolve(pending.account).resolve("pref.json"); UserPreference restored; + FileContents contents; try { - restored = parse(files.read(path)); + contents = files.readContents(path); } catch (NoSuchFileException exception) { restored = new UserPreference(); + publishPreferences(pending, path, restored); + return; + } + try { + if (contents.text == null) { + throw new JsonSyntaxException("Preferences are not valid UTF-8"); + } + restored = parse(contents.text); + } catch (IOException | RuntimeException exception) { + failCorrupt(pending, path, contents, exception); + return; } publishPreferences(pending, path, restored); } catch (IOException | RuntimeException exception) { @@ -459,6 +525,101 @@ private void fail(Attempt pending, Throwable failure) { publishState(version, State.FAILED); } + private void failCorrupt(Attempt pending, Path path, FileContents contents, Throwable failure) { + refreshAccount(); + long version; + synchronized (lock) { + if (!matches(pending)) { + return; + } + clear(State.CORRUPT); + failedPath = path; + corruptContents = contents; + version = generation; + } + cancel(pending); + CopilotCore.LOGGER.error("Failed to load chat preferences", failure); + publishState(version, State.CORRUPT); + } + + private void restore(Recovery recovery) { + if (!isCurrentRecovery(recovery)) { + return; + } + try { + boolean restored = files.restore(recovery.path, recovery.contents, GSON.toJson(new UserPreference()), + () -> isCurrentRecovery(recovery)); + if (!isCurrentRecovery(recovery)) { + return; + } + if (!restored) { + reloadAfterRecovery(recovery); + return; + } + publishRestoredDefaults(recovery); + } catch (IOException | RuntimeException exception) { + failRestore(recovery, exception); + } + } + + private void publishRestoredDefaults(Recovery recovery) { + readiness.getRealm().asyncExec(() -> { + refreshAccount(); + synchronized (lock) { + if (!matchesRecovery(recovery) || readiness.isDisposed()) { + return; + } + preferences = new UserPreference(); + preferencePath = recovery.path; + failedPath = null; + corruptContents = null; + state = State.READY; + if (!readiness.isDisposed()) { + readiness.setValue(State.READY); + } + } + }); + } + + private void failRestore(Recovery recovery, Throwable failure) { + refreshAccount(); + long version; + synchronized (lock) { + if (!matchesRecovery(recovery)) { + return; + } + state = State.RESTORE_FAILED; + version = generation; + } + CopilotCore.LOGGER.error("Failed to restore default chat preferences", failure); + publishState(version, State.RESTORE_FAILED); + } + + private void reloadAfterRecovery(Recovery recovery) { + synchronized (lock) { + if (!matchesRecovery(recovery)) { + return; + } + state = State.UNAVAILABLE; + failedPath = null; + corruptContents = null; + generation++; + } + start(true); + } + + private boolean isCurrentRecovery(Recovery recovery) { + refreshAccount(); + synchronized (lock) { + return matchesRecovery(recovery); + } + } + + private boolean matchesRecovery(Recovery recovery) { + return state == State.RESTORING && generation == recovery.generation + && Objects.equals(account, recovery.account) && Objects.equals(account, currentAccount()); + } + private void publishState(long version, State next) { readiness.getRealm().asyncExec(() -> { refreshAccount(); @@ -486,10 +647,74 @@ private void cancel(Attempt pending) { } } + static boolean restoreFile(Path path, FileContents expected, String content, BooleanSupplier isCurrent) + throws IOException { + Path parent = path.getParent(); + if (!isCurrent.getAsBoolean() || !matchesFile(path, expected.bytes)) { + return false; + } + Path backup = null; + Path temporary = null; + try { + backup = Files.createTempFile(parent, path.getFileName() + ".corrupt-", ".bak"); + try { + Files.write(backup, expected.bytes, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); + } catch (IOException exception) { + Files.deleteIfExists(backup); + backup = null; + throw exception; + } + if (!isCurrent.getAsBoolean() || !matchesFile(path, expected.bytes)) { + return false; + } + temporary = Files.createTempFile(parent, path.getFileName() + ".restore-", ".tmp"); + Files.writeString(temporary, content, StandardCharsets.UTF_8, StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING); + if (!isCurrent.getAsBoolean() || !matchesFile(path, expected.bytes)) { + return false; + } + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + temporary = null; + return true; + } finally { + if (temporary != null) { + Files.deleteIfExists(temporary); + } + } + } + + private static boolean matchesFile(Path path, byte[] expected) throws IOException { + try { + return Arrays.equals(Files.readAllBytes(path), expected); + } catch (NoSuchFileException exception) { + return false; + } + } + interface FileAccess { String read(Path path) throws IOException; + default FileContents readContents(Path path) throws IOException { + String text = read(path); + return new FileContents(text.getBytes(StandardCharsets.UTF_8), text); + } + void write(Path path, String content) throws IOException; + + default boolean restore(Path path, FileContents expected, String content, BooleanSupplier isCurrent) + throws IOException { + return restoreFile(path, expected, content, isCurrent); + } + } + + static final class FileContents { + private final byte[] bytes; + private final String text; + + FileContents(byte[] bytes, String text) { + this.bytes = bytes.clone(); + this.text = text; + } } /** @@ -508,5 +733,19 @@ private static class Attempt { this.account = account; this.started = started; } + + private static class Recovery { + private final long generation; + private final String account; + private final Path path; + private final FileContents contents; + + Recovery(long generation, String account, Path path, FileContents contents) { + this.generation = generation; + this.account = account; + this.path = path; + this.contents = contents; + } + } } }