Skip to content

Commit

Permalink
feat: lsp/extension to show build status for failures and errs (#1830)
Browse files Browse the repository at this point in the history
  • Loading branch information
gak and alecthomas authored Jun 19, 2024
1 parent 9941e4b commit 6e2a7d2
Show file tree
Hide file tree
Showing 7 changed files with 152 additions and 33 deletions.
2 changes: 1 addition & 1 deletion buildengine/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func Deploy(ctx context.Context, module Module, replicas int32, waitForDeployOnl
return nil
}

func teminateModuleDeployment(ctx context.Context, client ftlv1connect.ControllerServiceClient, module string) error {
func terminateModuleDeployment(ctx context.Context, client ftlv1connect.ControllerServiceClient, module string) error {
logger := log.FromContext(ctx).Scope(module)

status, err := client.Status(ctx, connect.NewRequest(&ftlv1.StatusRequest{}))
Expand Down
49 changes: 44 additions & 5 deletions buildengine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,16 @@ type moduleMeta struct {
}

type Listener interface {
// OnBuildStarted is called when a build is started for a project.
OnBuildStarted(module Module)
}

type BuildStartedListenerFunc func(module Module)
// OnBuildSuccess is called when all modules have been built successfully and deployed.
OnBuildSuccess()

func (b BuildStartedListenerFunc) OnBuildStarted(module Module) { b(module) }
// OnBuildFailed is called for any build failures.
// OnBuildSuccess should not be called if this is called after a OnBuildStarted.
OnBuildFailed(err error)
}

// Engine for building a set of modules.
type Engine struct {
Expand Down Expand Up @@ -227,6 +231,18 @@ func (e *Engine) Dev(ctx context.Context, period time.Duration) error {
return e.watchForModuleChanges(ctx, period)
}

func (e *Engine) reportBuildFailed(err error) {
if e.listener != nil {
e.listener.OnBuildFailed(err)
}
}

func (e *Engine) reportSuccess() {
if e.listener != nil {
e.listener.OnBuildSuccess()
}
}

func (e *Engine) watchForModuleChanges(ctx context.Context, period time.Duration) error {
logger := log.FromContext(ctx)

Expand All @@ -253,23 +269,28 @@ func (e *Engine) watchForModuleChanges(ctx context.Context, period time.Duration
err = e.buildAndDeploy(ctx, 1, true)
if err != nil {
logger.Errorf(err, "initial deploy failed")
e.reportBuildFailed(err)
} else {
logger.Infof("All modules deployed, watching for changes...")
e.reportSuccess()
}

moduleHashes := map[string][]byte{}
e.controllerSchema.Range(func(name string, sch *schema.Module) bool {
hash, err := computeModuleHash(sch)
if err != nil {
logger.Errorf(err, "compute hash for %s failed", name)
e.reportBuildFailed(err)
return false
}
moduleHashes[name] = hash
return true
})

// Watch for file and schema changes
didUpdateDeployments := false
// Track if there was an error, so that when deployments are complete we don't report success.
didError := false
// Watch for file and schema changes
for {
var completedUpdatesTimer <-chan time.Time
if didUpdateDeployments {
Expand All @@ -280,15 +301,23 @@ func (e *Engine) watchForModuleChanges(ctx context.Context, period time.Duration
return ctx.Err()
case <-completedUpdatesTimer:
logger.Infof("All modules deployed, watching for changes...")
// Some cases, this will trigger after a build failure, so report accordingly.
if !didError {
e.reportSuccess()
}

didUpdateDeployments = false
case event := <-watchEvents:
switch event := event.(type) {
case WatchEventModuleAdded:
config := event.Module.Config
if _, exists := e.moduleMetas.Load(config.Module); !exists {
e.moduleMetas.Store(config.Module, moduleMeta{module: event.Module})
didError = false
err := e.buildAndDeploy(ctx, 1, true, config.Module)
if err != nil {
didError = true
e.reportBuildFailed(err)
logger.Errorf(err, "deploy %s failed", config.Module)
} else {
didUpdateDeployments = true
Expand All @@ -297,8 +326,10 @@ func (e *Engine) watchForModuleChanges(ctx context.Context, period time.Duration
case WatchEventModuleRemoved:
config := event.Module.Config

err := teminateModuleDeployment(ctx, e.client, config.Module)
err := terminateModuleDeployment(ctx, e.client, config.Module)
if err != nil {
didError = true
e.reportBuildFailed(err)
logger.Errorf(err, "terminate %s failed", config.Module)
} else {
didUpdateDeployments = true
Expand All @@ -318,8 +349,11 @@ func (e *Engine) watchForModuleChanges(ctx context.Context, period time.Duration
logger.Debugf("Skipping build and deploy; event time %v is before the last build time %v", event.Time, meta.lastBuildStartTime)
continue // Skip this event as it's outdated
}
didError = false
err := e.buildAndDeploy(ctx, 1, true, config.Module)
if err != nil {
didError = true
e.reportBuildFailed(err)
logger.Errorf(err, "build and deploy failed for module %q", event.Module.Config.Module)
} else {
didUpdateDeployments = true
Expand All @@ -332,6 +366,8 @@ func (e *Engine) watchForModuleChanges(ctx context.Context, period time.Duration

hash, err := computeModuleHash(change.Module)
if err != nil {
didError = true
e.reportBuildFailed(err)
logger.Errorf(err, "compute hash for %s failed", change.Name)
continue
}
Expand All @@ -346,8 +382,11 @@ func (e *Engine) watchForModuleChanges(ctx context.Context, period time.Duration
dependentModuleNames := e.getDependentModuleNames(change.Name)
if len(dependentModuleNames) > 0 {
logger.Infof("%s's schema changed; processing %s", change.Name, strings.Join(dependentModuleNames, ", "))
didError = false
err = e.buildAndDeploy(ctx, 1, true, dependentModuleNames...)
if err != nil {
didError = true
e.reportBuildFailed(err)
logger.Errorf(err, "deploy %s failed", change.Name)
} else {
didUpdateDeployments = true
Expand Down
6 changes: 1 addition & 5 deletions cmd/ftl/cmd_dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func (d *devCmd) Run(ctx context.Context, projConfig projectconfig.Config) error
opts := []buildengine.Option{buildengine.Parallelism(d.Parallelism)}
if d.Lsp {
d.languageServer = lsp.NewServer(ctx)
opts = append(opts, buildengine.WithListener(buildengine.BuildStartedListenerFunc(d.OnBuildStarted)))
opts = append(opts, buildengine.WithListener(d.languageServer))
ctx = log.ContextWithLogger(ctx, log.FromContext(ctx).AddSink(lsp.NewLogSink(d.languageServer)))
g.Go(func() error {
return d.languageServer.Run()
Expand All @@ -93,7 +93,3 @@ func (d *devCmd) Run(ctx context.Context, projConfig projectconfig.Config) error

return g.Wait()
}

func (d *devCmd) OnBuildStarted(module buildengine.Module) {
d.languageServer.BuildStarted(module.Config.Dir)
}
28 changes: 22 additions & 6 deletions extensions/vscode/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
import { FTLStatus } from './status'

export class FTLClient {
private clientName = 'ftl languge server'
private clientName = 'ftl language server'
private clientId = 'ftl'

private statusBarItem: vscode.StatusBarItem
Expand Down Expand Up @@ -59,18 +59,34 @@ export class FTLClient {
serverOptions,
clientOptions
)

context.subscriptions.push(this.client)

const buildStatus = this.client.onNotification('ftl/buildState', (message) => {
console.log('Build status', message)
const state = message.state

if (state == 'building') {
FTLStatus.buildRunning(this.statusBarItem)
} else if (state == 'success') {
FTLStatus.buildOK(this.statusBarItem)
} else if (state == 'failure') {
FTLStatus.buildError(this.statusBarItem, message.error)
} else {
FTLStatus.ftlError(this.statusBarItem, 'Unknown build status from FTL LSP server')
this.outputChannel.appendLine(`Unknown build status from FTL LSP server: ${state}`)
}
})
context.subscriptions.push(buildStatus)

this.outputChannel.appendLine('Starting lsp client')
try {
await this.client.start()
this.outputChannel.appendLine('Client started')
console.log(`${this.clientName} started`)
FTLStatus.started(this.statusBarItem)
FTLStatus.buildOK(this.statusBarItem)
} catch (error) {
console.error(`Error starting ${this.clientName}: ${error}`)
FTLStatus.error(this.statusBarItem, `Error starting ${this.clientName}: ${error}`)
FTLStatus.ftlError(this.statusBarItem, `Error starting ${this.clientName}: ${error}`)
this.outputChannel.appendLine(`Error starting ${this.clientName}: ${error}`)
}

Expand Down Expand Up @@ -124,7 +140,7 @@ export class FTLClient {
try {
// Forcefully terminate if SIGTERM fails
process.kill(serverProcess.pid, 'SIGKILL')
console.log('Server process terminiated with SIGKILL')
console.log('Server process terminated with SIGKILL')
} catch (killError) {
console.log('Failed to kill server process', killError)
}
Expand All @@ -133,6 +149,6 @@ export class FTLClient {
console.log('Server process was already killed')
}

FTLStatus.stopped(this.statusBarItem)
FTLStatus.ftlStopped(this.statusBarItem)
}
}
10 changes: 5 additions & 5 deletions extensions/vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ const promptStartClient = async (context: vscode.ExtensionContext) => {
outputChannel.appendLine(`FTL configuration: ${JSON.stringify(configuration)}`)
const automaticallyStartServer = configuration.get<string>('automaticallyStartServer')

FTLStatus.stopped(statusBarItem)
FTLStatus.ftlStopped(statusBarItem)

if (automaticallyStartServer === 'always') {
outputChannel.appendLine(`FTL development server automatically started`)
Expand All @@ -132,19 +132,19 @@ const promptStartClient = async (context: vscode.ExtensionContext) => {
break
case 'No':
outputChannel.appendLine('FTL development server disabled')
FTLStatus.stopped(statusBarItem)
FTLStatus.ftlStopped(statusBarItem)
break
case 'Never':
outputChannel.appendLine('FTL development server set to never auto start')
configuration.update('automaticallyStartServer', 'never', vscode.ConfigurationTarget.Global)
FTLStatus.stopped(statusBarItem)
FTLStatus.ftlStopped(statusBarItem)
break
}
})
}

const startClient = async (context: ExtensionContext) => {
FTLStatus.starting(statusBarItem)
FTLStatus.ftlStarting(statusBarItem)

const ftlConfig = vscode.workspace.getConfiguration('ftl')
const workspaceRootPath = await getProjectOrWorkspaceRoot()
Expand All @@ -155,7 +155,7 @@ const startClient = async (context: ExtensionContext) => {

const ftlOK = await FTLPreflightCheck(resolvedFtlPath)
if (!ftlOK) {
FTLStatus.stopped(statusBarItem)
FTLStatus.ftlStopped(statusBarItem)
return
}

Expand Down
45 changes: 37 additions & 8 deletions extensions/vscode/src/status.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,49 @@
import * as vscode from 'vscode'

const resetColors = (statusBarItem: vscode.StatusBarItem) => {
statusBarItem.backgroundColor = undefined
statusBarItem.color = undefined
}

const errorColors = (statusBarItem: vscode.StatusBarItem) => {
statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground')
statusBarItem.color = new vscode.ThemeColor('statusBarItem.errorForeground')
}

const loadingColors = (statusBarItem: vscode.StatusBarItem) => {
statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground')
statusBarItem.color = new vscode.ThemeColor('statusBarItem.warningForeground')
}

export const FTLStatus = {
starting: (statusBarItem: vscode.StatusBarItem) => {
ftlStarting: (statusBarItem: vscode.StatusBarItem) => {
loadingColors(statusBarItem)
statusBarItem.text = `$(sync~spin) FTL`
statusBarItem.tooltip = 'FTL is starting...'
},
started: (statusBarItem: vscode.StatusBarItem) => {
statusBarItem.text = `$(zap) FTL`
statusBarItem.tooltip = 'FTL is running.'
},
stopped: (statusBarItem: vscode.StatusBarItem) => {
ftlStopped: (statusBarItem: vscode.StatusBarItem) => {
resetColors(statusBarItem)
statusBarItem.text = `$(primitive-square) FTL`
statusBarItem.tooltip = 'FTL is stopped.'
},
error: (statusBarItem: vscode.StatusBarItem, message: string) => {
ftlError: (statusBarItem: vscode.StatusBarItem, message: string) => {
errorColors(statusBarItem)
statusBarItem.text = `$(error) FTL`
statusBarItem.tooltip = message
}
},
buildRunning: (statusBarItem: vscode.StatusBarItem) => {
loadingColors(statusBarItem)
statusBarItem.text = `$(gear~spin) FTL`
statusBarItem.tooltip = 'FTL project building...'
},
buildOK: (statusBarItem: vscode.StatusBarItem) => {
resetColors(statusBarItem)
statusBarItem.text = `$(zap) FTL`
statusBarItem.tooltip = 'FTL project is successfully built.'
},
buildError: (statusBarItem: vscode.StatusBarItem, message: string) => {
errorColors(statusBarItem)
statusBarItem.text = `$(error) FTL`
statusBarItem.tooltip = message
},
}
45 changes: 42 additions & 3 deletions lsp/lsp.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/tliron/kutil/version"

"github.com/TBD54566975/ftl/backend/schema"
"github.com/TBD54566975/ftl/buildengine"
ftlErrors "github.com/TBD54566975/ftl/internal/errors"
"github.com/TBD54566975/ftl/internal/log"
)
Expand Down Expand Up @@ -71,9 +72,10 @@ func (s *Server) Run() error {

type errSet []*schema.Error

// BuildStarted clears diagnostics for the given directory. New errors will arrive later if they still exist.
func (s *Server) BuildStarted(dir string) {
dirURI := "file://" + dir
// OnBuildStarted clears diagnostics for the given directory. New errors will arrive later if they still exist.
// Also emit an FTL message to set the status.
func (s *Server) OnBuildStarted(module buildengine.Module) {
dirURI := "file://" + module.Config.Dir

s.diagnostics.Range(func(uri protocol.DocumentUri, diagnostics []protocol.Diagnostic) bool {
if strings.HasPrefix(uri, dirURI) {
Expand All @@ -82,6 +84,16 @@ func (s *Server) BuildStarted(dir string) {
}
return true
})

s.publishBuildState(buildStateBuilding, nil)
}

func (s *Server) OnBuildSuccess() {
s.publishBuildState(buildStateSuccess, nil)
}

func (s *Server) OnBuildFailed(err error) {
s.publishBuildState(buildStateFailure, err)
}

// Post sends diagnostics to the client.
Expand Down Expand Up @@ -182,6 +194,33 @@ func (s *Server) publishDiagnostics(uri protocol.DocumentUri, diagnostics []prot
})
}

type buildState string

const (
buildStateBuilding buildState = "building"
buildStateSuccess buildState = "success"
buildStateFailure buildState = "failure"
)

type buildStateMessage struct {
State buildState `json:"state"`
Err string `json:"error,omitempty"`
}

func (s *Server) publishBuildState(state buildState, err error) {
msg := buildStateMessage{State: state}
if err != nil {
msg.Err = err.Error()
}

s.logger.Debugf("Publishing build state: %s\n", msg)
if s.glspContext == nil {
return
}

go s.glspContext.Notify("ftl/buildState", msg)
}

func (s *Server) initialize() protocol.InitializeFunc {
return func(context *glsp.Context, params *protocol.InitializeParams) (any, error) {
s.glspContext = context
Expand Down

0 comments on commit 6e2a7d2

Please sign in to comment.