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"
release:
draft: true
github:
owner: rfwatson
name: octoplex
changelog:
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:
exclude:
- "^doc:"

View File

@ -27,8 +27,6 @@ type App struct {
dispatchC chan event.Command
dockerClient container.DockerClient
screen *terminal.Screen // Screen may be nil.
// TODO: startup check
// TODO: handle SIGINT
headless bool
clipboardAvailable bool
configFilePath string
@ -109,6 +107,19 @@ func (a *App) Run(ctx context.Context) error {
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"))
if err != nil {
err = fmt.Errorf("create container client: %w", err)
@ -119,10 +130,7 @@ func (a *App) Run(ctx context.Context) error {
} else {
msg = err.Error()
}
a.eventBus.Send(event.FatalErrorOccurredEvent{Message: msg})
emptyUI()
<-a.dispatchC
doFatalError(msg)
return err
}
defer containerClient.Close()
@ -152,9 +160,7 @@ func (a *App) Run(ctx context.Context) error {
})
if err != nil {
err = fmt.Errorf("create mediaserver: %w", err)
a.eventBus.Send(event.FatalErrorOccurredEvent{Message: err.Error()})
emptyUI()
<-a.dispatchC
doFatalError(err.Error())
return err
}
defer srv.Close()
@ -171,14 +177,16 @@ func (a *App) Run(ctx context.Context) error {
defer uiUpdateT.Stop()
startMediaServerC := make(chan struct{}, 1)
if a.headless { // disable startup check in headless mode for now
startMediaServerC <- struct{}{}
} else {
if ok, startupErr := doStartupCheck(ctx, containerClient, a.eventBus); startupErr != nil {
startupErr = fmt.Errorf("startup check: %w", startupErr)
a.eventBus.Send(event.FatalErrorOccurredEvent{Message: startupErr.Error()})
<-a.dispatchC
doFatalError(startupErr.Error())
return startupErr
} else if ok {
startMediaServerC <- struct{}{}
}
}
for {
select {
@ -202,6 +210,12 @@ func (a *App) Run(ctx context.Context) error {
updateUI()
case serverState := <-srv.C():
a.logger.Debug("Server state received", "state", serverState)
if serverState.ExitReason != "" {
doFatalError(serverState.ExitReason)
return errors.New("media server exited")
}
applyServerState(serverState, state)
updateUI()
case replState := <-repl.C():

View File

@ -892,13 +892,17 @@ func TestIntegrationMediaServerError(t *testing.T) {
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(
t,
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")
},
waitTime,

View File

@ -486,10 +486,6 @@ func (ui *UI) captureScreen(screen tcell.Screen) {
func (ui *UI) handleAppStateChanged(evt event.AppStateChangedEvent) {
state := evt.State
if state.Source.ExitReason != "" {
ui.handleMediaServerClosed(state.Source.ExitReason)
}
ui.updatePullProgress(state)
ui.mu.Lock()
@ -684,24 +680,6 @@ func (ui *UI) hideModal(pageName string) {
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 (

View File

@ -95,9 +95,11 @@ func run() error {
// When running in headless mode tview doesn't handle SIGINT for us.
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-ch
logger.Info("Received interrupt signal, exiting")
signal.Stop(ch)
cancel(errShutdown)
}()
}
@ -186,8 +188,8 @@ func printUsage() {
os.Stderr.WriteString(" help Print this help message\n")
os.Stderr.WriteString("\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_HEADLESS Enables headless mode if set (experimental)\n")
os.Stderr.WriteString(" OCTO_DEBUG Enables debug logging if set\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.