diff --git a/Makefile b/Makefile index 525114e3..0390bd06 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,8 @@ BRANCH := $(shell git rev-parse --abbrev-ref HEAD) BUILDDATE := $(shell date -u +%FT%T%z) BUILDTS := $(shell date -u +%s) REVISION := $(shell git rev-parse HEAD) -VERSION_DEV := 0.4.9-dev$(shell date +%Y%m%d%H%M) -VERSION := 0.4.8 +VERSION_DEV := 0.5.0-dev$(shell date +%Y%m%d%H%M) +VERSION := 0.4.9 PROMETHEUS_TAG := github.com/prometheus/common/version KVM_PKG_NAME := github.com/jetkvm/kvm diff --git a/failsafe.go b/failsafe.go index 14e7bbd5..d7de1c81 100644 --- a/failsafe.go +++ b/failsafe.go @@ -5,6 +5,8 @@ import ( "os" "strings" "sync" + + "github.com/jetkvm/kvm/internal/supervisor" ) const ( @@ -78,6 +80,10 @@ func checkFailsafeReason() { // TODO: read the goroutine stack trace and check which goroutine is panicking failsafeModeActive = true + if strings.Contains(failsafeCrashLog, supervisor.FailsafeReasonVideoMaxRestartAttemptsReached) { + failsafeModeReason = "video" + return + } if strings.Contains(failsafeCrashLog, "runtime.cgocall") { failsafeModeReason = "video" return diff --git a/internal/native/grpc_client.go b/internal/native/grpc_client.go index 300a2284..85a3201b 100644 --- a/internal/native/grpc_client.go +++ b/internal/native/grpc_client.go @@ -79,6 +79,18 @@ func NewGRPCClient(opts grpcClientOptions) (*GRPCClient, error) { // Start event stream go grpcClient.startEventStream() + // Start event handler to process events from the channel + go func() { + for { + select { + case event := <-grpcClient.eventCh: + grpcClient.handleEvent(event) + case <-grpcClient.eventDone: + return + } + } + }() + return grpcClient, nil } @@ -234,20 +246,6 @@ func (c *GRPCClient) handleEvent(event *pb.Event) { } } -// OnEvent registers an event handler -func (c *GRPCClient) OnEvent(eventType string, handler func(data interface{})) { - go func() { - for { - select { - case event := <-c.eventCh: - c.handleEvent(event) - case <-c.eventDone: - return - } - } - }() -} - // Close closes the gRPC client func (c *GRPCClient) Close() error { c.closeM.Lock() diff --git a/internal/native/grpc_server.go b/internal/native/grpc_server.go index 304203ce..9b54fb5b 100644 --- a/internal/native/grpc_server.go +++ b/internal/native/grpc_server.go @@ -15,18 +15,20 @@ import ( // grpcServer wraps the Native instance and implements the gRPC service type grpcServer struct { pb.UnimplementedNativeServiceServer - native *Native - logger *zerolog.Logger - eventChs []chan *pb.Event - eventM sync.Mutex + native *Native + logger *zerolog.Logger + eventStreamChan chan *pb.Event + eventStreamMu sync.Mutex + eventStreamCtx context.Context + eventStreamCancel context.CancelFunc } // NewGRPCServer creates a new gRPC server for the native service func NewGRPCServer(n *Native, logger *zerolog.Logger) *grpcServer { s := &grpcServer{ - native: n, - logger: logger, - eventChs: make([]chan *pb.Event, 0), + native: n, + logger: logger, + eventStreamChan: make(chan *pb.Event, 100), } // Store original callbacks and wrap them to also broadcast events @@ -82,16 +84,7 @@ func NewGRPCServer(n *Native, logger *zerolog.Logger) *grpcServer { } func (s *grpcServer) broadcastEvent(event *pb.Event) { - s.eventM.Lock() - defer s.eventM.Unlock() - - for _, ch := range s.eventChs { - select { - case ch <- event: - default: - // Channel full, skip - } - } + s.eventStreamChan <- event } func (s *grpcServer) IsReady(ctx context.Context, req *pb.IsReadyRequest) (*pb.IsReadyResponse, error) { @@ -103,35 +96,49 @@ func (s *grpcServer) StreamEvents(req *pb.Empty, stream pb.NativeService_StreamE setProcTitle("connected") defer setProcTitle("waiting") - eventCh := make(chan *pb.Event, 100) + // Cancel previous stream if exists + s.eventStreamMu.Lock() + if s.eventStreamCancel != nil { + s.logger.Debug().Msg("cancelling previous StreamEvents call") + s.eventStreamCancel() + } - // Register this channel for events - s.eventM.Lock() - s.eventChs = append(s.eventChs, eventCh) - s.eventM.Unlock() + // Create a cancellable context for this stream + ctx, cancel := context.WithCancel(stream.Context()) + s.eventStreamCtx = ctx + s.eventStreamCancel = cancel + s.eventStreamMu.Unlock() - // Unregister on exit + // Clean up when this stream ends defer func() { - s.eventM.Lock() - defer s.eventM.Unlock() - for i, ch := range s.eventChs { - if ch == eventCh { - s.eventChs = append(s.eventChs[:i], s.eventChs[i+1:]...) - break - } + s.eventStreamMu.Lock() + defer s.eventStreamMu.Unlock() + if s.eventStreamCtx == ctx { + s.eventStreamCancel = nil + s.eventStreamCtx = nil } - close(eventCh) + cancel() }() // Stream events for { select { - case event := <-eventCh: + case event := <-s.eventStreamChan: + // Check if this stream is still the active one + s.eventStreamMu.Lock() + isActive := s.eventStreamCtx == ctx + s.eventStreamMu.Unlock() + + if !isActive { + s.logger.Debug().Msg("stream replaced by new call, exiting") + return context.Canceled + } + if err := stream.Send(event); err != nil { return err } - case <-stream.Context().Done(): - return stream.Context().Err() + case <-ctx.Done(): + return ctx.Err() } } } diff --git a/internal/native/proxy.go b/internal/native/proxy.go index dda6a86e..01f0bb11 100644 --- a/internal/native/proxy.go +++ b/internal/native/proxy.go @@ -15,6 +15,7 @@ import ( "time" "github.com/Masterminds/semver/v3" + "github.com/jetkvm/kvm/internal/supervisor" "github.com/jetkvm/kvm/internal/utils" "github.com/rs/zerolog" ) @@ -422,7 +423,7 @@ func (p *NativeProxy) restartProcess() error { logger := p.logger.With().Uint("attempt", p.restarts).Uint("maxAttempts", p.options.MaxRestartAttempts).Logger() if p.restarts >= p.options.MaxRestartAttempts { - logger.Fatal().Msg("max restart attempts reached, exiting") + logger.Fatal().Msgf("max restart attempts reached, exiting: %s", supervisor.FailsafeReasonVideoMaxRestartAttemptsReached) return fmt.Errorf("max restart attempts reached") } diff --git a/internal/network/types/config.go b/internal/network/types/config.go index 364f8609..33afbcc7 100644 --- a/internal/network/types/config.go +++ b/internal/network/types/config.go @@ -30,7 +30,7 @@ type MDNSListenOptions struct { // NetworkConfig represents the complete network configuration for an interface type NetworkConfig struct { - DHCPClient null.String `json:"dhcp_client,omitempty" one_of:"jetdhcpc,udhcpc" default:"jetdhcpc"` + DHCPClient null.String `json:"dhcp_client,omitempty" one_of:"jetdhcpc,udhcpc" default:"udhcpc"` Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"` HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"` diff --git a/internal/supervisor/consts.go b/internal/supervisor/consts.go index 5f288489..ab250553 100644 --- a/internal/supervisor/consts.go +++ b/internal/supervisor/consts.go @@ -6,4 +6,6 @@ const ( ErrorDumpDir = "/userdata/jetkvm/crashdump" // The error dump directory is the directory where the error dumps are stored ErrorDumpLastFile = "last-crash.log" // The error dump last file is the last error dump file ErrorDumpTemplate = "jetkvm-%s.log" // The error dump template is the template for the error dump file + + FailsafeReasonVideoMaxRestartAttemptsReached = "failsafe::video.max_restart_attempts_reached" ) diff --git a/main.go b/main.go index a4d80fb7..88d2dec7 100644 --- a/main.go +++ b/main.go @@ -70,9 +70,6 @@ func Main() { initOta() - initNative(systemVersionLocal, appVersionLocal) - initDisplay() - http.DefaultClient.Timeout = 1 * time.Minute // Initialize network diff --git a/network.go b/network.go index ab6e3ee7..eb14c70d 100644 --- a/network.go +++ b/network.go @@ -284,6 +284,11 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re l.Info().Msg("IPv6 mode changed with udhcpc, reboot required") } + if newConfig.Hostname.String != oldConfig.Hostname.String { + rebootRequired = true + l.Info().Msg("Hostname changed, reboot required") + } + return rebootRequired, postRebootAction } diff --git a/ui/src/components/FailSafeModeOverlay.tsx b/ui/src/components/FailSafeModeOverlay.tsx index d519aded..2ea6c3b6 100644 --- a/ui/src/components/FailSafeModeOverlay.tsx +++ b/ui/src/components/FailSafeModeOverlay.tsx @@ -1,10 +1,9 @@ import { useState } from "react"; import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import { motion, AnimatePresence } from "framer-motion"; -import { LuInfo } from "react-icons/lu"; import { Button } from "@/components/Button"; -import Card, { GridCard } from "@components/Card"; +import { GridCard } from "@components/Card"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useVersion } from "@/hooks/useVersion"; @@ -32,39 +31,12 @@ function OverlayContent({ children }: OverlayContentProps) { ); } -interface TooltipProps { - readonly children: React.ReactNode; - readonly text: string; - readonly show: boolean; -} - -function Tooltip({ children, text, show }: TooltipProps) { - if (!show) { - return <>{children}; - } - - return ( -
- {children} -
- -
- - {text} -
-
-
-
- ); -} - export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) { const { send } = useJsonRpc(); const { navigateTo } = useDeviceUiNavigation(); const { appVersion } = useVersion(); const { systemVersion } = useDeviceStore(); const [isDownloadingLogs, setIsDownloadingLogs] = useState(false); - const [hasDownloadedLogs, setHasDownloadedLogs] = useState(false); const getReasonCopy = () => { switch (reason) { @@ -112,7 +84,6 @@ export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) { URL.revokeObjectURL(url); notifications.success("Crash logs downloaded successfully"); - setHasDownloadedLogs(true); // Open GitHub issue const issueBody = `## Issue Description @@ -143,7 +114,7 @@ Please attach the recovery logs file that was downloaded to your computer: }; const handleDowngrade = () => { - navigateTo(`/settings/general/update?app=${DOWNGRADE_VERSION}`); + navigateTo(`/settings/general/update?custom_app_version=${DOWNGRADE_VERSION}`); }; return ( diff --git a/ui/src/components/SettingsItem.tsx b/ui/src/components/SettingsItem.tsx index ac68bcf1..277da694 100644 --- a/ui/src/components/SettingsItem.tsx +++ b/ui/src/components/SettingsItem.tsx @@ -5,13 +5,22 @@ interface SettingsItemProps { readonly title: string; readonly description: string | React.ReactNode; readonly badge?: string; + readonly badgeTheme?: keyof typeof badgeTheme; readonly className?: string; readonly loading?: boolean; readonly children?: React.ReactNode; } +const badgeTheme = { + info: "bg-blue-500 text-white", + success: "bg-green-500 text-white", + warning: "bg-yellow-500 text-white", + danger: "bg-red-500 text-white", +}; + export function SettingsItem(props: SettingsItemProps) { - const { title, description, badge, children, className, loading } = props; + const { title, description, badge, badgeTheme: badgeThemeProp = "danger", children, className, loading } = props; + const badgeThemeClass = badgeTheme[badgeThemeProp]; return (