diff --git a/.agents/notes/implemented/bug-fix/2026-11-13-viva-plugin-install-enables-and-uninstall-cleans-state.md b/.agents/notes/implemented/bug-fix/2026-11-13-viva-plugin-install-enables-and-uninstall-cleans-state.md index d0dea8e20..fdda8445d 100644 --- a/.agents/notes/implemented/bug-fix/2026-11-13-viva-plugin-install-enables-and-uninstall-cleans-state.md +++ b/.agents/notes/implemented/bug-fix/2026-11-13-viva-plugin-install-enables-and-uninstall-cleans-state.md @@ -4,7 +4,7 @@ ## 先说结论 -Viva 客户端从插件市场装好插件后,插件默认是禁用的,用户要再去打开开关,语言服务器才会启动;卸载后,插件写进 `<插件根>/.state/<插件 ID>` 的文件(例如解压出来的 JDK 源码)还留在磁盘上。现在的做法是:用户在审查框确认安装,就等于选择使用这个插件,插件直接启用,但用户以前明确禁用过的选择保持不变。卸载时先停掉插件的语言服务器和后端,再删除插件包、批准记录、启用选择和整个 `.state/<插件 ID>`。禁用插件时,同样停掉它的后端。 +Viva 客户端从插件市场装好插件后,插件默认是禁用的,用户要再去打开开关,语言服务器才会启动;卸载后,插件写进 `<插件根>/.state/<插件 ID>` 的文件(例如解压出来的 JDK 源码)还留在磁盘上。现在的做法是:用户在审查框确认安装,就等于选择使用这个插件,插件直接启用,但用户以前明确禁用过的选择保持不变。卸载时先等待语言服务器、后端、构建与版本控制提供者、调试适配器退出,再删除插件包、批准记录、启用选择和整个 `.state/<插件 ID>`;停止失败时保留文件。禁用插件时,同样停掉它的后端。 ## 问题 @@ -39,7 +39,17 @@ Viva 客户端从插件市场装好插件后,插件默认是禁用的,用户 - 用户从商店装好带语言服务器的插件,打开对应文件即可使用。 - 卸载会删除插件的全部运行时状态,包括较大的源码副本和索引;重装后需要重新生成,首次跳转进 JDK 会再解压一次。 - 卸载时要等进程停止(有界:语言服务器最长按 `pluginInstallTimeout`,后端按 `backendStopTimeout`),再删文件,卸载完成会稍晚出现在界面上。 -- 构建 Provider、VCS Provider 和调试适配器进程仍没有在删包前停止,已建单 #1843。工作台三处写死 `".state"` 的路径拼接已建单 #1841。启用规则和步骤顺序已按 #1842 迁入 Core。 +- #1843 补齐构建提供者、版本控制提供者和调试适配器:卸载先阻断本插件的新启动,再等待所有项目标签(含隐藏标签)的原生会话结束,最后执行 Core 计划中的删包、清批准和删状态。停止失败或等待超时时保留文件;不能把“已经发送停止”当成“进程已经退出”。工作台三处写死 `".state"` 的路径拼接仍由 #1841 跟踪。启用规则和步骤顺序已按 #1842 迁入 Core。 + +### 卸载期间的原生启动与清理(#1843) + +构建和版本控制提供者是独立进程,不属于插件后端或语言服务器;调试适配器还可能通过语言服务器共用连接。因此不能只调用后端停止,也不能在删包后的界面回调里清理这些进程。 + +桌面端按进程所有权等待原生清理,Core 继续决定卸载步骤。提供者从创建状态目录前开始登记启动,卸载取消启动并等待它完成;迟到的启动必须用独立的清理期限停止自己,不能用已经取消的启动上下文清理进程。版本控制列表刷新时保留启动阻断,避免替换运行对象后重新创建已删除的状态;用户重新启用或确认安装时,即使列表刷新已经移除了旧会话对象,也要恢复这个保留的启动入口。调试会话沿用原有的父子会话、Core 销毁和 Run 进程清理顺序,并额外发布清理完成事件供卸载等待。 + +正确做法是“阻断启动 → 等待原生退出 → 删文件”;不要在后台启动停止任务后立即返回成功,也不要忽略清理失败后继续删除。已登记的提供者停止失败时保留其原生句柄供重试;启动阶段发生无法确认的清理失败则继续阻断删除,退出应用完成进程清理后再重试。原生登记与等待只在存在插件任务时分配资源,不增加轮询、定时器或常驻线程。 + +多个用户生命周期操作重叠仍需要单独的共享事务,例如卸载等待时又从另一个标签启用或确认安装。现有独立后台入口的串行化与迟到回调归属债务由 [#1918](https://github.com/1lck/Lithe-IDEA/issues/1918) 跟踪;本修复验证单次卸载与正在启动、运行的原生会话之间的顺序,不声明多个包修改操作已具备原子性。 ## 验证 @@ -49,6 +59,7 @@ Viva 客户端从插件市场装好插件后,插件默认是禁用的,用户 - `TestUninstallRemovesThePluginState`:删除本插件的 `.state/<插件 ID>`(含版本目录和 `pages/workspaces`),保留其他插件和 `host-secret`。 - `TestUninstallStopsTheBackendBeforeRemovingThePlugin`、`TestDisableStopsTheBackend`:后端在删包前停止、禁用先记录后停止、其他插件后端不受影响。 - `TestConfirmedInstallIsEnabledAndUninstallRemovesItsState`:工作台从磁盘安装并确认后插件已启用,卸载后插件包和状态目录都不存在。 +- #1843 的自动回归使用虚构清单及复制的测试程序,不依赖任何具体插件:`TestUninstallWaitsForTheDebugAdapter`、`TestUninstallStopsProviderSessionsBeforeDeletingFiles` 检查进程先退出/销毁再删包;`TestUninstallWaitsForAProviderStillStartingInHiddenTab` 用受控事件检查取消、等待迟到启动、隐藏标签和卸载后的启动阻断;`TestUninstallPreservesFilesWhenProgramsCannotStop` 检查停止失败时包、批准和状态保留。macOS 已验证,Windows 文件锁场景仍需原生验证。 - 2026-11-13 在 macOS 上用 `-tags vivahead` 构建的无头客户端(真实渲染,临时用户目录)从线上 Lithe Store 安装 Java Navigation 0.0.1:确认后显示“已启用”,F12 跳到项目内定义和 JDK `String.trim`,卸载后插件包、进程和 `.state/<插件 ID>` 都不存在,再按 F12 不跳转、不重新拉起进程,退出后无残留进程。Windows 共用同一份 Go 代码,尚未在 Windows 上验证。 ## 适用范围 diff --git a/desktop/internal/buildprovider/buildprovider.go b/desktop/internal/buildprovider/buildprovider.go index e4c4ca805..cfae978fe 100644 --- a/desktop/internal/buildprovider/buildprovider.go +++ b/desktop/internal/buildprovider/buildprovider.go @@ -177,7 +177,9 @@ func Start(ctx context.Context, runner *pluginbackend.Runner, request pluginback // it instead of leaving an unusable process behind. stopCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - _ = client.Close(stopCtx) + if stopErr := client.Close(stopCtx); stopErr != nil { + return nil, errors.Join(err, stopErr) + } return nil, err } return client, nil diff --git a/desktop/internal/pluginmanager/pluginmanager.go b/desktop/internal/pluginmanager/pluginmanager.go index 19b42fefc..40bb7ad39 100644 --- a/desktop/internal/pluginmanager/pluginmanager.go +++ b/desktop/internal/pluginmanager/pluginmanager.go @@ -105,9 +105,9 @@ func (m *Manager) Installed() ([]Entry, error) { // changed the user's choice for the plugin. stop stops what the plugin runs and // returns once it ended; nil means the caller runs nothing of the plugin. // -// Steps run in Core's order and a failed one does not stop the rest, so a failure -// to clean up one record never leaves a half-removed package that still looks -// installed; every failure is returned for the caller to log. An approval is only +// Steps run in Core's order. A process that could not stop prevents any file +// removal: its executable may be locked, and it may still write state. Other +// cleanup failures are collected for the caller to log. An approval is only // forgotten once its version is gone, so the next installation of a version that // could not be removed asks the user again. func (m *Manager) Apply(ctx context.Context, action core.PluginLifecycleAction, id string, stop func() error) (Outcome, error) { @@ -136,7 +136,7 @@ func (m *Manager) Apply(ctx context.Context, action core.PluginLifecycleAction, case core.PluginStopPrograms: if stop != nil { if err := stop(); err != nil { - failures = append(failures, err) + return outcome, errors.Join(append(failures, err)...) } } case core.PluginRemoveVersion: diff --git a/desktop/internal/pluginmanager/pluginmanager_test.go b/desktop/internal/pluginmanager/pluginmanager_test.go index 26bc0cb10..5a4023c03 100644 --- a/desktop/internal/pluginmanager/pluginmanager_test.go +++ b/desktop/internal/pluginmanager/pluginmanager_test.go @@ -133,6 +133,30 @@ func TestInstalledReportsVersionsAndEnablement(t *testing.T) { } } +func TestUninstallPreservesFilesWhenProgramsCannotStop(t *testing.T) { + manager, approvals, _, root := newManager(t) + const id = "dev.lithe.test.blocked-stop" + installed := install(t, root, id, "1.0.0") + approve(t, approvals, id, "1.0.0") + state := filepath.Join(root, pluginpkg.StateDirectory, id, "cache") + if err := os.MkdirAll(state, 0o700); err != nil { + t.Fatal(err) + } + stopErr := errors.New("the native process has not exited") + _, err := manager.Apply(context.Background(), core.PluginUninstall, id, func() error { return stopErr }) + if !errors.Is(err, stopErr) { + t.Fatalf("uninstall = %v, want the stop failure", err) + } + for _, path := range []string{installed.Path, state} { + if _, err := os.Stat(path); err != nil { + t.Errorf("files were removed despite a live process: %s: %v", path, err) + } + } + if _, ok, err := manager.Approval(id, "1.0.0"); err != nil || !ok { + t.Fatalf("approval was lost: %v, %v", ok, err) + } +} + func TestInstalledIsEmptyWithoutRoot(t *testing.T) { manager, _, _, _ := newManager(t) entries, err := manager.Installed() diff --git a/desktop/internal/pluginservice/lifecycle.go b/desktop/internal/pluginservice/lifecycle.go index 3eb41e781..0a47e0694 100644 --- a/desktop/internal/pluginservice/lifecycle.go +++ b/desktop/internal/pluginservice/lifecycle.go @@ -2,6 +2,7 @@ package pluginservice import ( "context" + "errors" "github.com/1lck/Lithe-IDEA/desktop/internal/core" ) @@ -14,9 +15,18 @@ import ( // // The programs stop first, and Uninstall waits for them (bounded): one still // running could write its state again after the removal, and Windows cannot -// move the directory of a program that runs. -func (s *Service) Uninstall(ctx context.Context, id string, server *LanguageSession) error { - _, err := s.Manager.Apply(ctx, core.PluginUninstall, id, func() error { return s.stopPlugin(ctx, id, server) }) +// move the directory of a program that runs. stopNative waits for the window's +// other native owners, including other project tabs, before stopping the language +// server and backend. A failed stop preserves the package and state for retry. +func (s *Service) Uninstall(ctx context.Context, id string, server *LanguageSession, stopNative ...func(context.Context) error) error { + _, err := s.Manager.Apply(ctx, core.PluginUninstall, id, func() error { + var failures []error + for _, stop := range stopNative { + failures = append(failures, stop(ctx)) + } + failures = append(failures, s.stopPlugin(ctx, id, server)) + return errors.Join(failures...) + }) return err } diff --git a/desktop/internal/workbench/build.go b/desktop/internal/workbench/build.go index 278a7c7fa..5f81b9555 100644 --- a/desktop/internal/workbench/build.go +++ b/desktop/internal/workbench/build.go @@ -204,6 +204,7 @@ func (a *App) attachProcess(p *project, op *buildOperation, process *buildproces // buildRuntime owns provider sessions for one project. A session is keyed by // plugin and build system, so two projects never share a process. type buildRuntime struct { + launches pluginLaunches mu sync.Mutex providers map[string]*buildprovider.Client // shared maps a plugin and language to the client attached to that @@ -220,7 +221,7 @@ type buildRuntime struct { // to another one or to a detach is stopped again. func (r *buildRuntime) provider(ctx context.Context, runner *pluginbackend.Runner, pluginRoot, root, rootURI string, instance buildInstance, settings core.PluginSettingsState) (*buildprovider.Client, error) { key := instance.PluginID + "\x00" + instance.BuildSystemID - errClosing := errors.New("the project is closing") + errClosing := buildprovider.ErrClosed r.mu.Lock() closed, client, detached := r.closed, r.providers[key], r.detached r.mu.Unlock() @@ -263,7 +264,7 @@ func (r *buildRuntime) provider(ctx context.Context, runner *pluginbackend.Runne return nil, err } r.mu.Lock() - current := !r.closed && r.detached == detached + current := !r.closed && r.detached == detached && ctx.Err() == nil client = r.providers[key] if current && (client == nil || client.Closed()) { if r.providers == nil { @@ -274,7 +275,11 @@ func (r *buildRuntime) provider(ctx context.Context, runner *pluginbackend.Runne return started, nil } r.mu.Unlock() - _ = started.Close(ctx) + stopCtx, cancel := context.WithTimeout(context.Background(), buildStopTimeout) + defer cancel() + if err := started.Close(stopCtx); err != nil { + return nil, err + } if !current { return nil, errClosing } @@ -314,7 +319,12 @@ func buildStateDirectory(pluginRoot, workspace string, instance buildInstance) s // buildProvider starts a provider process, or attaches to the language server // that shares it. The caller runs this away from the window thread. -func (a *App) buildProvider(ctx context.Context, p *project, instance buildInstance) (*buildprovider.Client, error) { +func (a *App) buildProvider(ctx context.Context, p *project, instance buildInstance) (client *buildprovider.Client, err error) { + ctx, finish, err := p.buildSessions.launches.begin(ctx, instance.PluginID) + if err != nil { + return nil, err + } + defer func() { finish(err) }() settings, err := a.loadPluginPreferenceSources(ctx, instance.Manifest, p.root) if err != nil { return nil, err @@ -323,7 +333,7 @@ func (a *App) buildProvider(ctx context.Context, p *project, instance buildInsta if err != nil { return nil, err } - client, err := p.buildSessions.provider(ctx, a.plugins.Backend, a.plugins.Root, p.root, rootURI, instance, settings.State) + client, err = p.buildSessions.provider(ctx, a.plugins.Backend, a.plugins.Root, p.root, rootURI, instance, settings.State) var shared *pluginbackend.SharedProviderError if !errors.As(err, &shared) { return client, err diff --git a/desktop/internal/workbench/debug.go b/desktop/internal/workbench/debug.go index 6b27dc445..2bd4aae38 100644 --- a/desktop/internal/workbench/debug.go +++ b/desktop/internal/workbench/debug.go @@ -259,8 +259,12 @@ type debugSession struct { created bool operationMu sync.Mutex cleanupOnce sync.Once - writeMu sync.Mutex - cancel context.CancelFunc + // cleanupDone publishes native teardown completion independently of the UI + // callback, so uninstall can wait before removing an adapter's package. + cleanupDone chan struct{} + cleanupErr error + writeMu sync.Mutex + cancel context.CancelFunc // stopped is set when Stop or project close already took the session apart. stopped atomic.Bool // muted is the project's mute switch as this session should have it; written on the @@ -809,6 +813,9 @@ func (a *App) stopDebugByUser(p *project) { // after startup returned, so cancellation cannot race a late session creation. // Note: Run 进程所有权见 .agents/notes/implemented/bug-fix/2026-11-13-viva-plugin-run-debug-lifecycle.md。 func (a *App) releaseDebugSession(p *project, session *debugSession) { + if session.cleanupDone == nil { + session.cleanupDone = make(chan struct{}) + } session.cleanupOnce.Do(func() { // A child that ends before its start was answered fails the parent's request. a.answerChildStart(p, session, false, a.text.Debug.Child.Stopped) @@ -829,6 +836,7 @@ func (a *App) releaseDebugSession(p *project, session *debugSession) { } // Cleanup belongs to the app, not the now-cancelled project. a.background(nil, 0, func(_ context.Context) func(*App) { + defer close(session.cleanupDone) if session.ended != nil { // The run that stands for the session ends once it is fully released. defer func() { session.ended <- session.exitCode.Load() }() @@ -869,6 +877,7 @@ func (a *App) releaseDebugSession(p *project, session *debugSession) { if session.stateDirectory != "" { cleanupErr = errors.Join(cleanupErr, os.RemoveAll(session.stateDirectory)) } + session.cleanupErr = cleanupErr return func(a *App) { a.releaseChildSessions(p, children, destroyed) if cleanupErr != nil { diff --git a/desktop/internal/workbench/debug_start.go b/desktop/internal/workbench/debug_start.go index 72d0a3165..da3c57cdd 100644 --- a/desktop/internal/workbench/debug_start.go +++ b/desktop/internal/workbench/debug_start.go @@ -383,6 +383,12 @@ func (a *App) beginDebug(p *project, start debugStart) { return } adapter := start.adapter + a.pluginServerMu.Lock() + suppressed := a.pluginServerSuppressed[adapter.PluginID] + a.pluginServerMu.Unlock() + if suppressed { + return + } targetPath := start.targetPath if doc := p.selectedDocument(p.docs.group); targetPath == "" && doc != nil { targetPath = doc.path diff --git a/desktop/internal/workbench/plugin_programs.go b/desktop/internal/workbench/plugin_programs.go new file mode 100644 index 000000000..7e53500ed --- /dev/null +++ b/desktop/internal/workbench/plugin_programs.go @@ -0,0 +1,209 @@ +package workbench + +import ( + "context" + "errors" + "strings" + "sync" + + "github.com/1lck/Lithe-IDEA/desktop/internal/buildprovider" +) + +// pluginLaunches owns native starts until they either publish a process or finish +// cleaning it up. stop closes admission before cancelling starts; its waiter must +// finish before the plugin's executable or state can be removed. +type pluginLaunches struct { + mu sync.Mutex + blocked map[string]bool + active map[*pluginLaunch]string +} + +type pluginLaunch struct { + cancel context.CancelFunc + done chan struct{} + err error +} + +func (r *pluginLaunches) begin(ctx context.Context, id string) (context.Context, func(error), error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.blocked[id] { + return nil, nil, buildprovider.ErrClosed + } + ctx, cancel := context.WithCancel(ctx) + launch := &pluginLaunch{cancel: cancel, done: make(chan struct{})} + if r.active == nil { + r.active = make(map[*pluginLaunch]string) + } + r.active[launch] = id + return ctx, func(err error) { + cancel() + r.mu.Lock() + // Cancellation and admission refusal are expected during uninstall. A + // different error may include failed process cleanup and prevents deletion. + if err != context.Canceled && err != context.DeadlineExceeded && err != buildprovider.ErrClosed { + launch.err = err + } + if launch.err == nil || !r.blocked[id] { + delete(r.active, launch) + } + close(launch.done) + r.mu.Unlock() + }, nil +} + +func (r *pluginLaunches) stop(id string) func(context.Context) error { + r.mu.Lock() + if r.blocked == nil { + r.blocked = make(map[string]bool) + } + r.blocked[id] = true + var pending []*pluginLaunch + for launch, owner := range r.active { + if owner == id { + launch.cancel() + pending = append(pending, launch) + } + } + r.mu.Unlock() + return func(ctx context.Context) error { + var failures []error + for _, launch := range pending { + select { + case <-launch.done: + failures = append(failures, launch.err) + case <-ctx.Done(): + return errors.Join(append(failures, ctx.Err())...) + } + } + return errors.Join(failures...) + } +} + +func (r *pluginLaunches) allow(id string) { + r.mu.Lock() + delete(r.blocked, id) + r.mu.Unlock() +} + +type pluginClient struct { + table map[string]*buildprovider.Client + key string + client *buildprovider.Client +} + +// takePluginClients snapshots native handles belonging to id. A failed stop +// keeps its handle registered so retrying uninstall must stop it again. +func takePluginClients(clients map[string]*buildprovider.Client, id string) []pluginClient { + var taken []pluginClient + for key, client := range clients { + if strings.HasPrefix(key, id+"\x00") { + taken = append(taken, pluginClient{table: clients, key: key, client: client}) + } + } + return taken +} + +func stopPluginClients(ctx context.Context, mu *sync.Mutex, clients []pluginClient, pending func(context.Context) error) error { + var failures []error + for _, owned := range clients { + if err := owned.client.Close(ctx); err != nil { + failures = append(failures, err) + continue + } + mu.Lock() + if owned.table[owned.key] == owned.client { + delete(owned.table, owned.key) + } + mu.Unlock() + } + failures = append(failures, pending(ctx)) + return errors.Join(failures...) +} + +func (r *buildRuntime) detachPlugin(id string) func(context.Context) error { + wait := r.launches.stop(id) + r.mu.Lock() + clients := append(takePluginClients(r.providers, id), takePluginClients(r.shared, id)...) + r.mu.Unlock() + return func(ctx context.Context) error { return stopPluginClients(ctx, &r.mu, clients, wait) } +} + +func (r *vcsRuntime) detachPlugin(id string) func(context.Context) error { + wait := r.launches.stop(id) + r.mu.Lock() + clients := takePluginClients(r.clients, id) + r.mu.Unlock() + return func(ctx context.Context) error { return stopPluginClients(ctx, &r.mu, clients, wait) } +} + +// detachPluginPrograms takes native handles on the UI thread, then returns the +// bounded cleanup performed by Core's stopPrograms step. A hidden project tab +// owns its own plugin stack and must finish using the same package too. +// Note: 卸载约束见 .agents/notes/implemented/bug-fix/2026-11-13-viva-plugin-install-enables-and-uninstall-cleans-state.md。 +func (a *App) detachPluginPrograms(id string) func(context.Context) error { + var stops []func(context.Context) error + for _, tab := range a.everyTab() { + service := tab.plugins + if service == nil { + continue + } + server := tab.detachPluginServer(id) + if p := tab.project; p != nil { + stops = append(stops, p.buildSessions.detachPlugin(id)) + stops = append(stops, p.vcs.runtime().detachPlugin(id)) + var debugging []*debugSession + for _, session := range p.debug.sessions { + if session.adapter.PluginID == id { + if session.cleanupDone == nil { + session.cleanupDone = make(chan struct{}) + } + debugging = append(debugging, session) + } + } + for _, session := range debugging { + if p.debug.hasSession(session) { + tab.endDebugSession(p, session) + } + } + stops = append(stops, func(ctx context.Context) error { + var failures []error + for _, session := range debugging { + select { + case <-session.cleanupDone: + failures = append(failures, session.cleanupErr) + case <-ctx.Done(): + return errors.Join(append(failures, ctx.Err())...) + } + } + return errors.Join(failures...) + }) + } + stops = append(stops, func(ctx context.Context) error { + return service.StopDisplaced(ctx, id, server) + }) + } + return func(ctx context.Context) error { + var failures []error + for _, stop := range stops { + failures = append(failures, stop(ctx)) + } + return errors.Join(failures...) + } +} + +// allowPluginPrograms opens native launch admission after an installation or +// explicit enable. The old starts were awaited before the package changed. +func (a *App) allowPluginPrograms(id string) { + for _, tab := range a.everyTab() { + tab.pluginServerMu.Lock() + delete(tab.pluginServerSuppressed, id) + tab.pluginServerMu.Unlock() + if p := tab.project; p != nil { + p.buildSessions.launches.allow(id) + if p.vcs.launches != nil { + p.vcs.launches.allow(id) + } + } + } +} diff --git a/desktop/internal/workbench/plugins.go b/desktop/internal/workbench/plugins.go index 7ad1a302f..525b645ce 100644 --- a/desktop/internal/workbench/plugins.go +++ b/desktop/internal/workbench/plugins.go @@ -223,9 +223,7 @@ func (a *App) confirmPluginInstall() { // The installation was just enabled, unless the user had disabled the // plugin, so a disable tombstone from a previous version must not // block its server. - a.pluginServerMu.Lock() - delete(a.pluginServerSuppressed, plan.ID) - a.pluginServerMu.Unlock() + a.allowPluginPrograms(plan.ID) } a.stopDisplaced(displaced) a.refreshPluginState() @@ -279,9 +277,7 @@ func (a *App) setPluginEnabled(id string, enabled bool) { } if enabled { // An enable clears the tombstone a disable left for an in-flight start. - a.pluginServerMu.Lock() - delete(a.pluginServerSuppressed, id) - a.pluginServerMu.Unlock() + a.allowPluginPrograms(id) } a.stopDisplaced(displaced) // Enabling and disabling both change the detected build systems. @@ -304,18 +300,22 @@ func (a *App) uninstallPlugin(id string) { if state := (*states)[id]; !state.Installed { return } - // The server leaves the workbench now; Uninstall stops it and the plugin's - // backend before it removes the package and the state. - server := a.detachPluginServer(id) + // Detach native owners on their window thread before asking Core to remove + // anything. The stopPrograms step waits for every tab, including late starts. + stop := a.detachPluginPrograms(id) a.background(nil, pluginInstallTimeout, func(ctx context.Context) func(*App) { - err := a.plugins.Uninstall(ctx, id, server) + err := a.plugins.Uninstall(ctx, id, nil, stop) return func(a *App) { if err != nil { a.emit(operationFailedLevel, operationFailedEvent, operationFailedFields("plugin_uninstall", err)) } - a.invalidateBuild(a.project) - a.refreshPluginState() - a.reloadPluginLanguages() + for _, tab := range a.everyTab() { + if err == nil { + tab.invalidateBuild(tab.project) + tab.reloadPluginLanguages() + } + tab.refreshPluginState() + } } }) } diff --git a/desktop/internal/workbench/plugins_test.go b/desktop/internal/workbench/plugins_test.go index a6c5f2226..cbaa91404 100644 --- a/desktop/internal/workbench/plugins_test.go +++ b/desktop/internal/workbench/plugins_test.go @@ -2,13 +2,16 @@ package workbench import ( "archive/zip" + "context" "encoding/json" "errors" "os" "path/filepath" + "sync" "testing" "time" + "github.com/1lck/Lithe-IDEA/desktop/internal/buildprovider" "github.com/1lck/Lithe-IDEA/desktop/internal/core" "github.com/1lck/Lithe-IDEA/desktop/internal/plugininstall" "github.com/1lck/Lithe-IDEA/desktop/internal/pluginpkg" @@ -191,3 +194,251 @@ func TestConfirmedInstallIsEnabledAndUninstallRemovesItsState(t *testing.T) { } } } + +// Removing an adapter's package must wait for the native process, not just the +// plugin backend: on Windows the executable cannot be removed while it runs. +func TestUninstallWaitsForTheDebugAdapter(t *testing.T) { + h := debugHarness(t) + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "debug.marker"), []byte("debug"), 0o600); err != nil { + t.Fatal(err) + } + p := openDebugPanel(t, h, root) + h.app.startDebug(p) + session := p.debug.activeSession() + awaitStateWithin(t, h, debugAdapterExitWait, "the adapter's ready event", func() bool { return p.debug.state == "paused" }) + h.app.refreshPluginState() + h.settle() + h.app.uninstallPlugin(session.adapter.PluginID) + awaitStateWithin(t, h, debugAdapterExitWait, "the adapter package's removal", func() bool { + _, err := os.Stat(session.adapter.Directory) + return errors.Is(err, os.ErrNotExist) + }) + select { + case <-session.exitObserved: + default: + t.Fatal("the package was removed before its adapter exited") + } + if p.debug.hasSession(session) { + t.Fatal("the uninstalled adapter's session is still in the panel") + } +} + +type uninstallOrderKernel struct { + *testKernel + directory string + destroyed chan bool +} + +func (k *uninstallOrderKernel) DestroyPluginBackend(ctx context.Context, id string) error { + _, statErr := os.Stat(k.directory) + err := k.Client.DestroyPluginBackend(ctx, id) + if err == nil { + k.destroyed <- statErr == nil + } + return err +} + +func TestUninstallStopsProviderSessionsBeforeDeletingFiles(t *testing.T) { + for _, role := range []string{"build", "vcs"} { + t.Run(role, func(t *testing.T) { + manifest := providerTestsManifest + if role == "vcs" { + manifest = vcsFixtureManifest + } + kernel := &uninstallOrderKernel{testKernel: &testKernel{Client: core.NewClient()}, destroyed: make(chan bool, 16)} + h := providerTestsHarnessWith(t, manifest, kernel) + const id = "dev.lithe.test.provider-tests" + directory := filepath.Join(h.app.plugins.Root, id, "1.0.0") + kernel.directory = directory + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "fixture.marker"), nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(root, ".fakevcs"), 0o700); err != nil { + t.Fatal(err) + } + h.open(root) + p := h.app.project + var client *buildprovider.Client + runtime := p.vcs.runtime() + h.app.background(p, 30*time.Second, func(ctx context.Context) func(*App) { + uri, err := h.app.kernel.PluginDocumentURI(ctx, root, "auto") + var started *buildprovider.Client + if err == nil && role == "build" { + started, err = h.app.buildProvider(ctx, p, buildInstance{ + BuildInstance: core.BuildInstance{BuildSystemID: "fixture-provider-tests"}, + PluginID: id, Version: "1.0.0", PackageDirectory: directory, Manifest: json.RawMessage(manifest), + }) + } else if err == nil { + started, err = runtime.session(ctx, h.app.plugins.Backend, h.app.plugins.Root, root, uri, + vcsWindow{PluginID: id, Version: "1.0.0", Directory: directory, Manifest: json.RawMessage(manifest), Vcs: "fakevcs"}, core.PluginSettingsState{}) + } + return func(*App) { + if err != nil { + t.Fatalf("start %s: %v", role, err) + } + client = started + } + }) + h.settle() + if client == nil { + t.Fatal("the provider did not start") + } + h.app.refreshPluginState() + h.settle() + h.app.uninstallPlugin(id) + awaitStateWithin(t, h, 30*time.Second, "the provider package's removal", func() bool { + _, err := os.Stat(directory) + return errors.Is(err, os.ErrNotExist) + }) + if !client.Closed() { + t.Fatal("the provider was still live when its files were removed") + } + h.settle() + select { + case present := <-kernel.destroyed: + if !present { + t.Fatal("the provider process was destroyed after its package was removed") + } + default: + t.Fatal("the provider process was never destroyed") + } + state := filepath.Join(h.app.plugins.Root, pluginpkg.StateDirectory, id) + if _, err := os.Stat(state); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("the provider's state survived: %v", err) + } + if role == "vcs" { + // Even a refresh already queued before uninstall must retain the + // launch barrier when it replaces the VCS runtime. + h.app.invalidateVcs(p) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _, err := p.vcs.runtime().session(ctx, h.app.plugins.Backend, h.app.plugins.Root, root, "", + vcsWindow{PluginID: id, Version: "1.0.0"}, core.PluginSettingsState{}) + if !errors.Is(err, buildprovider.ErrClosed) { + t.Fatalf("catalog refresh reopened a removed plugin: %v", err) + } + if _, err := os.Stat(state); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("a stale VCS request recreated state: %v", err) + } + } + }) + } +} + +// Hold a native start inside Core's launch plan after it has created its state +// directory. Uninstall must cancel it and wait even if that call returns late. +type uninstallStartKernel struct { + *testKernel + entered, cancelled, release chan struct{} + once sync.Once +} + +func (k *uninstallStartKernel) PlanPluginBuildProvider(ctx context.Context, _ json.RawMessage, _ string, _ core.PluginBackendDirectories) (core.PluginBackendPlan, error) { + k.once.Do(func() { close(k.entered) }) + select { + case <-ctx.Done(): + close(k.cancelled) + case <-time.After(30 * time.Second): + return core.PluginBackendPlan{}, errors.New("the start was never cancelled") + } + select { + case <-k.release: + return core.PluginBackendPlan{}, ctx.Err() + case <-time.After(30 * time.Second): + return core.PluginBackendPlan{}, errors.New("the test never released the start") + } +} + +func TestUninstallWaitsForAProviderStillStartingInHiddenTab(t *testing.T) { + kernel := &uninstallStartKernel{testKernel: &testKernel{Client: core.NewClient()}, entered: make(chan struct{}), cancelled: make(chan struct{}), release: make(chan struct{})} + h := providerTestsHarness(t, kernel) + hidden := newHarness(t, &testKernel{Client: core.NewClient()}) + service, err := pluginservice.OpenAnother(kernel, h.app.store, h.app.plugins.Root) + if err != nil { + t.Fatal(err) + } + hidden.app.plugins = service + hidden.open(t.TempDir()) + h.app.sessions = &windowSessions{list: []*App{h.app, hidden.app}, active: h.app} + h.app.refreshPluginState() + h.settle() + const id = "dev.lithe.test.provider-tests" + directory := filepath.Join(service.Root, id, "1.0.0") + var release sync.Once + t.Cleanup(func() { release.Do(func() { close(kernel.release) }) }) + hidden.app.background(hidden.app.project, 30*time.Second, func(ctx context.Context) func(*App) { + _, err := hidden.app.buildProvider(ctx, hidden.app.project, buildInstance{ + BuildInstance: core.BuildInstance{BuildSystemID: "fixture-provider-tests"}, PluginID: id, + Version: "1.0.0", PackageDirectory: directory, Manifest: json.RawMessage(providerTestsManifest), + }) + return func(*App) { + if !errors.Is(err, context.Canceled) { + t.Errorf("the cancelled start returned %v", err) + } + } + }) + select { + case <-kernel.entered: + case <-time.After(30 * time.Second): + t.Fatal("the provider never reached its launch plan") + } + h.app.uninstallPlugin(id) + select { + case <-kernel.cancelled: + case <-time.After(30 * time.Second): + t.Fatal("uninstall did not cancel the hidden tab's start") + } + if _, err := os.Stat(directory); err != nil { + t.Fatalf("uninstall removed the package while its start was still in flight: %v", err) + } + release.Do(func() { close(kernel.release) }) + h.settle() + hidden.settle() + for _, gone := range []string{directory, filepath.Join(service.Root, pluginpkg.StateDirectory, id)} { + if _, err := os.Stat(gone); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("the cancelled start left %s: %v", gone, err) + } + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _, err = hidden.app.buildProvider(ctx, hidden.app.project, buildInstance{PluginID: id}) + if !errors.Is(err, buildprovider.ErrClosed) { + t.Fatalf("a stale request could start the uninstalled plugin: %v", err) + } +} + +func TestEnableReopensVcsAfterCatalogRefresh(t *testing.T) { + h := providerTestsHarnessWith(t, vcsFixtureManifest) + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".fakevcs"), 0o700); err != nil { + t.Fatal(err) + } + h.open(root) + p := h.app.project + const id = "dev.lithe.test.provider-tests" + stop := p.vcs.runtime().detachPlugin(id) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := stop(ctx); err != nil { + t.Fatal(err) + } + h.app.invalidateVcs(p) + h.app.setPluginEnabled(id, true) + h.settle() + sessions := p.vcs.runtime() + h.app.background(p, 30*time.Second, func(ctx context.Context) func(*App) { + uri, err := h.app.kernel.PluginDocumentURI(ctx, root, "auto") + if err == nil { + _, err = sessions.session(ctx, h.app.plugins.Backend, h.app.plugins.Root, root, uri, + vcsWindow{PluginID: id, Version: "1.0.0", Directory: filepath.Join(h.app.plugins.Root, id, "1.0.0"), Manifest: json.RawMessage(vcsFixtureManifest), Vcs: "fakevcs"}, core.PluginSettingsState{}) + } + return func(*App) { + if err != nil { + t.Errorf("the enabled VCS provider could not start after a catalog refresh: %v", err) + } + } + }) + h.settle() +} diff --git a/desktop/internal/workbench/vcs.go b/desktop/internal/workbench/vcs.go index 05ed45c80..326958d0c 100644 --- a/desktop/internal/workbench/vcs.go +++ b/desktop/internal/workbench/vcs.go @@ -117,6 +117,9 @@ type vcsState struct { failure string // sessions are the provider sessions the project started, nil until the first starts. sessions *vcsRuntime + // launches survives catalog refreshes, so a pending refresh cannot reopen + // admission while uninstall is waiting for the old native sessions. + launches *pluginLaunches // views are the view of each window, by its chrome identifier. views map[string]*vcsView } @@ -341,9 +344,10 @@ func (s *vcsState) rejection(plugin string) string { // vcsRuntime holds the provider sessions of one project, one per provider. type vcsRuntime struct { - mu sync.Mutex - clients map[string]*buildprovider.Client - closed bool + launches *pluginLaunches + mu sync.Mutex + clients map[string]*buildprovider.Client + closed bool } // errVcsNotNegotiated is a session that started without negotiating the provider it was started @@ -367,7 +371,12 @@ func (r *vcsRuntime) live(key string) *buildprovider.Client { // negotiate the window's provider is stopped and refused, as the contract asks. The process starts // outside the lock, so detaching on the window thread never waits for a start; a start that lost // to another one or to detaching is stopped again. -func (r *vcsRuntime) session(ctx context.Context, runner *pluginbackend.Runner, pluginRoot, root, rootURI string, window vcsWindow, settings core.PluginSettingsState) (*buildprovider.Client, error) { +func (r *vcsRuntime) session(ctx context.Context, runner *pluginbackend.Runner, pluginRoot, root, rootURI string, window vcsWindow, settings core.PluginSettingsState) (client *buildprovider.Client, err error) { + ctx, finish, err := r.launches.begin(ctx, window.PluginID) + if err != nil { + return nil, err + } + defer func() { finish(err) }() key := window.PluginID + "\x00" + window.Vcs r.mu.Lock() closed, client := r.closed, r.clients[key] @@ -408,11 +417,12 @@ func (r *vcsRuntime) session(ctx context.Context, runner *pluginbackend.Runner, return nil, err } if vcs, _, ok := started.VcsProvider(); !ok || vcs != window.Vcs { - _ = started.Close(ctx) - return nil, errVcsNotNegotiated + stopCtx, cancel := context.WithTimeout(context.Background(), vcsStopTimeout) + defer cancel() + return nil, errors.Join(errVcsNotNegotiated, started.Close(stopCtx)) } r.mu.Lock() - closed, client = r.closed, r.clients[key] + closed, client = r.closed || ctx.Err() != nil, r.clients[key] if !closed && (client == nil || client.Closed()) { if r.clients == nil { r.clients = map[string]*buildprovider.Client{} @@ -422,7 +432,11 @@ func (r *vcsRuntime) session(ctx context.Context, runner *pluginbackend.Runner, return started, nil } r.mu.Unlock() - _ = started.Close(ctx) + stopCtx, cancel := context.WithTimeout(context.Background(), vcsStopTimeout) + defer cancel() + if err := started.Close(stopCtx); err != nil { + return nil, err + } if closed { return nil, buildprovider.ErrClosed } @@ -447,7 +461,10 @@ func (r *vcsRuntime) detach(closing bool) []*buildprovider.Client { // runtime is the project's sessions, made on first use. func (s *vcsState) runtime() *vcsRuntime { if s.sessions == nil { - s.sessions = &vcsRuntime{} + if s.launches == nil { + s.launches = &pluginLaunches{} + } + s.sessions = &vcsRuntime{launches: s.launches} } return s.sessions } @@ -470,7 +487,7 @@ func (a *App) invalidateVcs(p *project) { if p.vcs.sessions != nil { a.closeVcsSessions(p.vcs.sessions.detach(true)) } - p.vcs = vcsState{generation: p.vcs.generation + 1} + p.vcs = vcsState{generation: p.vcs.generation + 1, launches: p.vcs.launches} } // closeVcsSessions closes clients on a background task with a bounded wait. diff --git a/shared/platform-feature-matrix/features/plugin-extension-management.json b/shared/platform-feature-matrix/features/plugin-extension-management.json index ff8e58042..b39b68a5f 100644 --- a/shared/platform-feature-matrix/features/plugin-extension-management.json +++ b/shared/platform-feature-matrix/features/plugin-extension-management.json @@ -19,7 +19,10 @@ "desktop/internal/workbench/plugins.go", "desktop/internal/workbench/plugin_displace.go", "desktop/internal/pluginmanager/pluginmanager_test.go", - "desktop/internal/workbench/plugin_displace_test.go" + "desktop/internal/workbench/plugin_displace_test.go", + "desktop/internal/workbench/plugin_programs.go", + "desktop/internal/workbench/plugins_test.go", + ".agents/notes/implemented/bug-fix/2026-11-13-viva-plugin-install-enables-and-uninstall-cleans-state.md" ] }, "windows": { @@ -38,7 +41,10 @@ "desktop/internal/workbench/plugins.go", "desktop/internal/workbench/plugin_displace.go", "desktop/internal/pluginmanager/pluginmanager_test.go", - "desktop/internal/workbench/plugin_displace_test.go" + "desktop/internal/workbench/plugin_displace_test.go", + "desktop/internal/workbench/plugin_programs.go", + "desktop/internal/workbench/plugins_test.go", + ".agents/notes/implemented/bug-fix/2026-11-13-viva-plugin-install-enables-and-uninstall-cleans-state.md" ] }, "owner": "Extensions", @@ -72,5 +78,5 @@ "verificationStatus": "pending" } }, - "notes": "Viva:市场标签提供安装;已安装标签提供启用、禁用、更新与卸载(卸载前确认);从磁盘安装在插件页齿轮菜单的“从磁盘安装插件…”;plugininstall 负责校验与提交,pluginstate 保存启用状态。版本回退尚未实现。确认安装即启用(用户此前明确禁用过的,更新时保持禁用;卸载会清除选择);卸载和禁用先停止插件的语言服务器与后端,卸载再删除插件包和插件状态目录 `.state/<插件 ID>`。Windows 共用同一份 Go 代码,尚未在 Windows 上验证。 2026-11-14:Core 的 plugins.installed.lifecycle 按清单 languageServers 保证同一语言只有一个已启用插件(Lithe-core-rust#39),宿主执行 displacePlugin:记为停用、停止进程、提示用户,并为已打开的共享语言文档启动新插件的服务器;尚未在原生窗口和 Windows 上验证。" + "notes": "Viva:市场标签提供安装;已安装标签提供启用、禁用、更新与卸载(卸载前确认);从磁盘安装在插件页齿轮菜单的“从磁盘安装插件…”;plugininstall 负责校验与提交,pluginstate 保存启用状态。版本回退尚未实现。确认安装即启用(用户此前明确禁用过的,更新时保持禁用;卸载会清除选择);卸载和禁用先停止插件的语言服务器与后端,卸载再删除插件包和插件状态目录 `.state/<插件 ID>`。Windows 共用同一份 Go 代码,尚未在 Windows 上验证。 2026-11-14:Core 的 plugins.installed.lifecycle 按清单 languageServers 保证同一语言只有一个已启用插件(Lithe-core-rust#39),宿主执行 displacePlugin:记为停用、停止进程、提示用户,并为已打开的共享语言文档启动新插件的服务器;尚未在原生窗口和 Windows 上验证。 #1843:Viva 卸载前等待所有项目标签的构建提供者、版本控制提供者及调试适配器退出;取消并等待尚未完成的启动,列表刷新仍保留启动阻断。停止失败时保留插件文件、批准及状态。macOS 已用虚构清单和受控原生进程完成回归,Windows 文件锁工作流尚待原生验证。" }