Compare commits

..

18 Commits

Author SHA1 Message Date
d4e136c8d6 feat: headless mode (experimental) 2025-05-06 01:19:47 +02:00
2ab48bfc93 build: update .goreleaser.yaml 2025-05-06 01:11:18 +02:00
2bdb5335dc refactor(app): add Dispatch method 2025-05-06 01:11:18 +02:00
4be90993ef doc: update README 2025-05-06 01:11:18 +02:00
d1efffc937 refactor(app): internalize dispatch channel 2025-05-06 01:11:18 +02:00
d3171c6251 refactor: extract commands from domain 2025-05-06 01:11:18 +02:00
d9dbb7fc8f doc: add godoc 2025-05-06 01:11:18 +02:00
8c4974e0c1 refactor(app): add DestinationWentOffAirEvent 2025-05-06 01:11:18 +02:00
6bcd5d05c7 fix(app): event ordering
Use a single channel per consumer, instead of one channel per
consumer/event tuple. This ensures that overall ordering of events
remains consistent, and avoids introducing subtle race conditions.
2025-05-06 01:11:18 +02:00
c6ce8be5b1 refactor(app): async startup check 2025-05-06 01:11:18 +02:00
9c16275207 refactor(app): add StartDestinationFailedEvent 2025-05-06 01:11:18 +02:00
a80e891b75 refactor(app): add destination error events 2025-05-06 01:11:18 +02:00
9e2f6649eb refactor(app): add AppStateChangedEvent 2025-05-06 01:11:18 +02:00
81679be6c3 doc: update README 2025-05-06 01:11:18 +02:00
de0ecb1f34 refactor(app): extract more events 2025-05-06 01:11:18 +02:00
d96d26c29c refactor(app): extract handleCommand 2025-05-06 01:11:18 +02:00
4a2857e310 refactor(app): add App type 2025-05-06 01:11:18 +02:00
e8be872047 chore: remove stray file 2025-05-06 01:10:33 +02:00
5 changed files with 58 additions and 47 deletions

View File

@ -40,12 +40,25 @@ brews:
system "#{bin}/octoplex -h" system "#{bin}/octoplex -h"
release: release:
draft: true
github: github:
owner: rfwatson owner: rfwatson
name: octoplex name: octoplex
changelog: changelog:
use: github use: github
groups:
- title: New Features
regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$'
order: 0
- title: "Bug fixes"
regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$'
order: 1
- title: "Refactorings"
regexp: '^.*?refactor(\([[:word:]]+\))??!?:.+$'
order: 2
- title: Others
order: 999
filters: filters:
exclude: exclude:
- "^doc:" - "^doc:"

View File

@ -21,14 +21,12 @@ import (
// App is an instance of the app. // App is an instance of the app.
type App struct { type App struct {
cfg config.Config cfg config.Config
configService *config.Service configService *config.Service
eventBus *event.Bus eventBus *event.Bus
dispatchC chan event.Command dispatchC chan event.Command
dockerClient container.DockerClient dockerClient container.DockerClient
screen *terminal.Screen // Screen may be nil. screen *terminal.Screen // Screen may be nil.
// TODO: startup check
// TODO: handle SIGINT
headless bool headless bool
clipboardAvailable bool clipboardAvailable bool
configFilePath string configFilePath string
@ -109,6 +107,19 @@ func (a *App) Run(ctx context.Context) error {
a.eventBus.Send(event.AppStateChangedEvent{State: domain.AppState{}}) a.eventBus.Send(event.AppStateChangedEvent{State: domain.AppState{}})
} }
// doFatalError publishes a fatal error to the event bus, waiting for the
// user to acknowledge it if not in headless mode.
doFatalError := func(msg string) {
a.eventBus.Send(event.FatalErrorOccurredEvent{Message: msg})
if a.headless {
return
}
emptyUI()
<-a.dispatchC
}
containerClient, err := container.NewClient(ctx, a.dockerClient, a.logger.With("component", "container_client")) containerClient, err := container.NewClient(ctx, a.dockerClient, a.logger.With("component", "container_client"))
if err != nil { if err != nil {
err = fmt.Errorf("create container client: %w", err) err = fmt.Errorf("create container client: %w", err)
@ -119,10 +130,7 @@ func (a *App) Run(ctx context.Context) error {
} else { } else {
msg = err.Error() msg = err.Error()
} }
a.eventBus.Send(event.FatalErrorOccurredEvent{Message: msg}) doFatalError(msg)
emptyUI()
<-a.dispatchC
return err return err
} }
defer containerClient.Close() defer containerClient.Close()
@ -152,9 +160,7 @@ func (a *App) Run(ctx context.Context) error {
}) })
if err != nil { if err != nil {
err = fmt.Errorf("create mediaserver: %w", err) err = fmt.Errorf("create mediaserver: %w", err)
a.eventBus.Send(event.FatalErrorOccurredEvent{Message: err.Error()}) doFatalError(err.Error())
emptyUI()
<-a.dispatchC
return err return err
} }
defer srv.Close() defer srv.Close()
@ -171,13 +177,15 @@ func (a *App) Run(ctx context.Context) error {
defer uiUpdateT.Stop() defer uiUpdateT.Stop()
startMediaServerC := make(chan struct{}, 1) startMediaServerC := make(chan struct{}, 1)
if ok, startupErr := doStartupCheck(ctx, containerClient, a.eventBus); startupErr != nil { if a.headless { // disable startup check in headless mode for now
startupErr = fmt.Errorf("startup check: %w", startupErr)
a.eventBus.Send(event.FatalErrorOccurredEvent{Message: startupErr.Error()})
<-a.dispatchC
return startupErr
} else if ok {
startMediaServerC <- struct{}{} startMediaServerC <- struct{}{}
} else {
if ok, startupErr := doStartupCheck(ctx, containerClient, a.eventBus); startupErr != nil {
doFatalError(startupErr.Error())
return startupErr
} else if ok {
startMediaServerC <- struct{}{}
}
} }
for { for {
@ -202,6 +210,12 @@ func (a *App) Run(ctx context.Context) error {
updateUI() updateUI()
case serverState := <-srv.C(): case serverState := <-srv.C():
a.logger.Debug("Server state received", "state", serverState) a.logger.Debug("Server state received", "state", serverState)
if serverState.ExitReason != "" {
doFatalError(serverState.ExitReason)
return errors.New("media server exited")
}
applyServerState(serverState, state) applyServerState(serverState, state)
updateUI() updateUI()
case replState := <-repl.C(): case replState := <-repl.C():

View File

@ -892,13 +892,17 @@ func TestIntegrationMediaServerError(t *testing.T) {
done <- struct{}{} done <- struct{}{}
}() }()
require.NoError(t, app.New(buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)).Run(ctx)) require.EqualError(
t,
app.New(buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)).Run(ctx),
"media server exited",
)
}() }()
require.EventuallyWithT( require.EventuallyWithT(
t, t,
func(c *assert.CollectT) { func(c *assert.CollectT) {
assert.True(c, contentsIncludes(getContents(), "Mediaserver error: Server process exited unexpectedly."), "expected to see title") assert.True(c, contentsIncludes(getContents(), "Server process exited unexpectedly."), "expected to see title")
assert.True(c, contentsIncludes(getContents(), "address already in use"), "expected to see message") assert.True(c, contentsIncludes(getContents(), "address already in use"), "expected to see message")
}, },
waitTime, waitTime,

View File

@ -486,10 +486,6 @@ func (ui *UI) captureScreen(screen tcell.Screen) {
func (ui *UI) handleAppStateChanged(evt event.AppStateChangedEvent) { func (ui *UI) handleAppStateChanged(evt event.AppStateChangedEvent) {
state := evt.State state := evt.State
if state.Source.ExitReason != "" {
ui.handleMediaServerClosed(state.Source.ExitReason)
}
ui.updatePullProgress(state) ui.updatePullProgress(state)
ui.mu.Lock() ui.mu.Lock()
@ -684,24 +680,6 @@ func (ui *UI) hideModal(pageName string) {
ui.app.SetFocus(ui.destView) ui.app.SetFocus(ui.destView)
} }
func (ui *UI) handleMediaServerClosed(exitReason string) {
if ui.pages.HasPage(pageNameModalSourceError) {
return
}
modal := tview.NewModal()
modal.SetText("Mediaserver error: " + exitReason).
AddButtons([]string{"Quit"}).
SetBackgroundColor(tcell.ColorBlack).
SetTextColor(tcell.ColorWhite).
SetDoneFunc(func(int, string) {
ui.dispatch(event.CommandQuit{})
})
modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
ui.pages.AddPage(pageNameModalSourceError, modal, true, true)
}
const dash = "—" const dash = "—"
const ( const (

View File

@ -95,9 +95,11 @@ func run() error {
// When running in headless mode tview doesn't handle SIGINT for us. // When running in headless mode tview doesn't handle SIGINT for us.
ch := make(chan os.Signal, 1) ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
go func() { go func() {
<-ch <-ch
logger.Info("Received interrupt signal, exiting") logger.Info("Received interrupt signal, exiting")
signal.Stop(ch)
cancel(errShutdown) cancel(errShutdown)
}() }()
} }
@ -186,8 +188,8 @@ func printUsage() {
os.Stderr.WriteString(" help Print this help message\n") os.Stderr.WriteString(" help Print this help message\n")
os.Stderr.WriteString("\n") os.Stderr.WriteString("\n")
os.Stderr.WriteString("Additionally, Octoplex can be configured with the following environment variables:\n\n") os.Stderr.WriteString("Additionally, Octoplex can be configured with the following environment variables:\n\n")
os.Stderr.WriteString(" OCTO_DEBUG Enables debug logging if set\n\n") os.Stderr.WriteString(" OCTO_DEBUG Enables debug logging if set\n")
os.Stderr.WriteString(" OCTO_HEADLESS Enables headless mode if set (experimental)\n") os.Stderr.WriteString(" OCTO_HEADLESS Enables headless mode if set (experimental)\n\n")
} }
// buildLogger builds the logger, which may be a no-op logger. // buildLogger builds the logger, which may be a no-op logger.