Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## 先说结论

Viva 客户端从插件市场装好插件后,插件默认是禁用的,用户要再去打开开关,语言服务器才会启动;卸载后,插件写进 `<插件根>/.state/<插件 ID>` 的文件(例如解压出来的 JDK 源码)还留在磁盘上。现在的做法是:用户在审查框确认安装,就等于选择使用这个插件,插件直接启用,但用户以前明确禁用过的选择保持不变。卸载时先停掉插件的语言服务器和后端,再删除插件包、批准记录、启用选择和整个 `.state/<插件 ID>`。禁用插件时,同样停掉它的后端。
Viva 客户端从插件市场装好插件后,插件默认是禁用的,用户要再去打开开关,语言服务器才会启动;卸载后,插件写进 `<插件根>/.state/<插件 ID>` 的文件(例如解压出来的 JDK 源码)还留在磁盘上。现在的做法是:用户在审查框确认安装,就等于选择使用这个插件,插件直接启用,但用户以前明确禁用过的选择保持不变。卸载时先等待语言服务器、后端、构建与版本控制提供者、调试适配器退出,再删除插件包、批准记录、启用选择和整个 `.state/<插件 ID>`;停止失败时保留文件。禁用插件时,同样停掉它的后端。

## 问题

Expand Down Expand Up @@ -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) 跟踪;本修复验证单次卸载与正在启动、运行的原生会话之间的顺序,不声明多个包修改操作已具备原子性。

## 验证

Expand All @@ -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 上验证。

## 适用范围
Expand Down
4 changes: 3 additions & 1 deletion desktop/internal/buildprovider/buildprovider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions desktop/internal/pluginmanager/pluginmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions desktop/internal/pluginmanager/pluginmanager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
16 changes: 13 additions & 3 deletions desktop/internal/pluginservice/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package pluginservice

import (
"context"
"errors"

"github.com/1lck/Lithe-IDEA/desktop/internal/core"
)
Expand All @@ -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
}

Expand Down
20 changes: 15 additions & 5 deletions desktop/internal/workbench/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 11 additions & 2 deletions desktop/internal/workbench/debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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() }()
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions desktop/internal/workbench/debug_start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading