diff --git a/cmd/main.go b/cmd/main.go
index 59033c47..9a1e1899 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -16,10 +16,10 @@ import (
)
const (
- envChildID = "JETKVM_CHILD_ID"
- errorDumpDir = "/userdata/jetkvm/"
- errorDumpStateFile = ".has_error_dump"
- errorDumpTemplate = "jetkvm-%s.log"
+ envChildID = "JETKVM_CHILD_ID"
+ errorDumpDir = "/userdata/jetkvm/crashdump"
+ errorDumpLastFile = "last-crash.log"
+ errorDumpTemplate = "jetkvm-%s.log"
)
func program() {
@@ -74,7 +74,12 @@ func supervise() error {
// run the child binary
cmd := exec.Command(binPath)
- cmd.Env = append(os.Environ(), []string{envChildID + "=" + kvm.GetBuiltAppVersion()}...)
+ lastFilePath := filepath.Join(errorDumpDir, errorDumpLastFile)
+
+ cmd.Env = append(os.Environ(), []string{
+ fmt.Sprintf("%s=%s", envChildID, kvm.GetBuiltAppVersion()),
+ fmt.Sprintf("JETKVM_LAST_ERROR_PATH=%s", lastFilePath),
+ }...)
cmd.Args = os.Args
logFile, err := os.CreateTemp("", "jetkvm-stdout.log")
@@ -117,30 +122,47 @@ func supervise() error {
return nil
}
-func createErrorDump(logFile *os.File) {
- logFile.Close()
-
- // touch the error dump state file
- if err := os.WriteFile(filepath.Join(errorDumpDir, errorDumpStateFile), []byte{}, 0644); err != nil {
- return
- }
-
- fileName := fmt.Sprintf(errorDumpTemplate, time.Now().Format("20060102150405"))
- filePath := filepath.Join(errorDumpDir, fileName)
- if err := os.Rename(logFile.Name(), filePath); err == nil {
- fmt.Printf("error dump created: %s\n", filePath)
- return
- }
-
- fnSrc, err := os.Open(logFile.Name())
+func isSymlinkTo(oldName, newName string) bool {
+ file, err := os.Stat(newName)
if err != nil {
- return
+ return false
+ }
+ if file.Mode()&os.ModeSymlink != os.ModeSymlink {
+ return false
+ }
+ target, err := os.Readlink(newName)
+ if err != nil {
+ return false
+ }
+ return target == oldName
+}
+
+func ensureSymlink(oldName, newName string) error {
+ if isSymlinkTo(oldName, newName) {
+ return nil
+ }
+ _ = os.Remove(newName)
+ return os.Symlink(oldName, newName)
+}
+
+func renameFile(f *os.File, newName string) error {
+ _ = f.Close()
+
+ // try to rename the file first
+ if err := os.Rename(f.Name(), newName); err == nil {
+ return nil
+ }
+
+ // copy the log file to the error dump directory
+ fnSrc, err := os.Open(f.Name())
+ if err != nil {
+ return fmt.Errorf("failed to open file: %w", err)
}
defer fnSrc.Close()
- fnDst, err := os.Create(filePath)
+ fnDst, err := os.Create(newName)
if err != nil {
- return
+ return fmt.Errorf("failed to create file: %w", err)
}
defer fnDst.Close()
@@ -148,18 +170,60 @@ func createErrorDump(logFile *os.File) {
for {
n, err := fnSrc.Read(buf)
if err != nil && err != io.EOF {
- return
+ return fmt.Errorf("failed to read file: %w", err)
}
if n == 0 {
break
}
if _, err := fnDst.Write(buf[:n]); err != nil {
- return
+ return fmt.Errorf("failed to write file: %w", err)
}
}
- fmt.Printf("error dump created: %s\n", filePath)
+ return nil
+}
+
+func ensureErrorDumpDir() error {
+ // TODO: check if the directory is writable
+ f, err := os.Stat(errorDumpDir)
+ if err == nil && f.IsDir() {
+ return nil
+ }
+ if err := os.MkdirAll(errorDumpDir, 0755); err != nil {
+ return fmt.Errorf("failed to create error dump directory: %w", err)
+ }
+ return nil
+}
+
+func createErrorDump(logFile *os.File) {
+ fmt.Println()
+
+ fileName := fmt.Sprintf(
+ errorDumpTemplate,
+ time.Now().Format("20060102-150405"),
+ )
+
+ // check if the directory exists
+ if err := ensureErrorDumpDir(); err != nil {
+ fmt.Printf("failed to ensure error dump directory: %v\n", err)
+ return
+ }
+
+ filePath := filepath.Join(errorDumpDir, fileName)
+ if err := renameFile(logFile, filePath); err != nil {
+ fmt.Printf("failed to rename file: %v\n", err)
+ return
+ }
+
+ fmt.Printf("error dump copied: %s\n", filePath)
+
+ lastFilePath := filepath.Join(errorDumpDir, errorDumpLastFile)
+
+ if err := ensureSymlink(filePath, lastFilePath); err != nil {
+ fmt.Printf("failed to create symlink: %v\n", err)
+ return
+ }
}
func doSupervise() {
diff --git a/failsafe.go b/failsafe.go
new file mode 100644
index 00000000..3c6b3d3a
--- /dev/null
+++ b/failsafe.go
@@ -0,0 +1,107 @@
+package kvm
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "sync"
+)
+
+const (
+ failsafeDefaultLastCrashPath = "/userdata/jetkvm/crashdump/last-crash.log"
+ failsafeFile = "/userdata/jetkvm/.enablefailsafe"
+ failsafeLastCrashEnv = "JETKVM_LAST_ERROR_PATH"
+ failsafeEnv = "JETKVM_FORCE_FAILSAFE"
+)
+
+var (
+ failsafeOnce sync.Once
+ failsafeCrashLog = ""
+ failsafeModeActive = false
+ failsafeModeReason = ""
+)
+
+type FailsafeModeNotification struct {
+ Active bool `json:"active"`
+ Reason string `json:"reason"`
+}
+
+// this function has side effects and can be only executed once
+func checkFailsafeReason() {
+ failsafeOnce.Do(func() {
+ // check if the failsafe environment variable is set
+ if os.Getenv(failsafeEnv) == "1" {
+ failsafeModeActive = true
+ failsafeModeReason = "failsafe_env_set"
+ return
+ }
+
+ // check if the failsafe file exists
+ if _, err := os.Stat(failsafeFile); err == nil {
+ failsafeModeActive = true
+ failsafeModeReason = "failsafe_file_exists"
+ _ = os.Remove(failsafeFile)
+ return
+ }
+
+ // get the last crash log path from the environment variable
+ lastCrashPath := os.Getenv(failsafeLastCrashEnv)
+ if lastCrashPath == "" {
+ lastCrashPath = failsafeDefaultLastCrashPath
+ }
+
+ // check if the last crash log file exists
+ l := failsafeLogger.With().Str("path", lastCrashPath).Logger()
+ fi, err := os.Lstat(lastCrashPath)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ l.Warn().Err(err).Msg("failed to stat last crash log")
+ }
+ return
+ }
+
+ if fi.Mode()&os.ModeSymlink != os.ModeSymlink {
+ l.Warn().Msg("last crash log is not a symlink, ignoring")
+ return
+ }
+
+ // open the last crash log file and find if it contains the string "panic"
+ content, err := os.ReadFile(lastCrashPath)
+ if err != nil {
+ l.Warn().Err(err).Msg("failed to read last crash log")
+ return
+ }
+
+ // unlink the last crash log file
+ failsafeCrashLog = string(content)
+ _ = os.Remove(lastCrashPath)
+
+ // TODO: read the goroutine stack trace and check which goroutine is panicking
+ if strings.Contains(failsafeCrashLog, "runtime.cgocall") {
+ failsafeModeActive = true
+ failsafeModeReason = "video"
+ return
+ }
+ })
+}
+
+func notifyFailsafeMode(session *Session) {
+ if !failsafeModeActive || session == nil {
+ return
+ }
+
+ jsonRpcLogger.Info().Str("reason", failsafeModeReason).Msg("sending failsafe mode notification")
+
+ writeJSONRPCEvent("failsafeMode", FailsafeModeNotification{
+ Active: true,
+ Reason: failsafeModeReason,
+ }, session)
+}
+
+func rpcGetFailsafeLogs() (string, error) {
+ if !failsafeModeActive {
+ return "", fmt.Errorf("failsafe mode is not active")
+ }
+
+ return failsafeCrashLog, nil
+}
diff --git a/internal/native/cgo_linux.go b/internal/native/cgo_linux.go
index 850da0e8..be1a5a36 100644
--- a/internal/native/cgo_linux.go
+++ b/internal/native/cgo_linux.go
@@ -1,5 +1,8 @@
//go:build linux
+// TODO: use a generator to generate the cgo code for the native functions
+// there's too much boilerplate code to write manually
+
package native
import (
@@ -46,7 +49,17 @@ static inline void jetkvm_cgo_setup_rpc_handler() {
*/
import "C"
-var cgoLock sync.Mutex
+var (
+ cgoLock sync.Mutex
+ cgoDisabled bool
+)
+
+func setCgoDisabled(disabled bool) {
+ cgoLock.Lock()
+ defer cgoLock.Unlock()
+
+ cgoDisabled = disabled
+}
//export jetkvm_go_video_state_handler
func jetkvm_go_video_state_handler(state *C.jetkvm_video_state_t) {
@@ -91,6 +104,10 @@ func jetkvm_go_rpc_handler(method *C.cchar_t, params *C.cchar_t) {
var eventCodeToNameMap = map[int]string{}
func uiEventCodeToName(code int) string {
+ if cgoDisabled {
+ return ""
+ }
+
name, ok := eventCodeToNameMap[code]
if !ok {
cCode := C.int(code)
@@ -103,6 +120,10 @@ func uiEventCodeToName(code int) string {
}
func setUpNativeHandlers() {
+ if cgoDisabled {
+ return
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -114,6 +135,10 @@ func setUpNativeHandlers() {
}
func uiInit(rotation uint16) {
+ if cgoDisabled {
+ return
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -123,6 +148,10 @@ func uiInit(rotation uint16) {
}
func uiTick() {
+ if cgoDisabled {
+ return
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -130,6 +159,10 @@ func uiTick() {
}
func videoInit(factor float64) error {
+ if cgoDisabled {
+ return nil
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -143,6 +176,10 @@ func videoInit(factor float64) error {
}
func videoShutdown() {
+ if cgoDisabled {
+ return
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -150,6 +187,10 @@ func videoShutdown() {
}
func videoStart() {
+ if cgoDisabled {
+ return
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -157,6 +198,10 @@ func videoStart() {
}
func videoStop() {
+ if cgoDisabled {
+ return
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -164,6 +209,10 @@ func videoStop() {
}
func videoLogStatus() string {
+ if cgoDisabled {
+ return ""
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -174,6 +223,10 @@ func videoLogStatus() string {
}
func uiSetVar(name string, value string) {
+ if cgoDisabled {
+ return
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -187,6 +240,10 @@ func uiSetVar(name string, value string) {
}
func uiGetVar(name string) string {
+ if cgoDisabled {
+ return ""
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -197,6 +254,10 @@ func uiGetVar(name string) string {
}
func uiSwitchToScreen(screen string) {
+ if cgoDisabled {
+ return
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -206,6 +267,10 @@ func uiSwitchToScreen(screen string) {
}
func uiGetCurrentScreen() string {
+ if cgoDisabled {
+ return ""
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -214,6 +279,10 @@ func uiGetCurrentScreen() string {
}
func uiObjAddState(objName string, state string) (bool, error) {
+ if cgoDisabled {
+ return false, nil
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -226,6 +295,10 @@ func uiObjAddState(objName string, state string) (bool, error) {
}
func uiObjClearState(objName string, state string) (bool, error) {
+ if cgoDisabled {
+ return false, nil
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -238,6 +311,10 @@ func uiObjClearState(objName string, state string) (bool, error) {
}
func uiGetLVGLVersion() string {
+ if cgoDisabled {
+ return ""
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -246,6 +323,10 @@ func uiGetLVGLVersion() string {
// TODO: use Enum instead of string but it's not a hot path and performance is not a concern now
func uiObjAddFlag(objName string, flag string) (bool, error) {
+ if cgoDisabled {
+ return false, nil
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -258,6 +339,10 @@ func uiObjAddFlag(objName string, flag string) (bool, error) {
}
func uiObjClearFlag(objName string, flag string) (bool, error) {
+ if cgoDisabled {
+ return false, nil
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -278,6 +363,10 @@ func uiObjShow(objName string) (bool, error) {
}
func uiObjSetOpacity(objName string, opacity int) (bool, error) {
+ if cgoDisabled {
+ return false, nil
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -289,6 +378,10 @@ func uiObjSetOpacity(objName string, opacity int) (bool, error) {
}
func uiObjFadeIn(objName string, duration uint32) (bool, error) {
+ if cgoDisabled {
+ return false, nil
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -301,6 +394,10 @@ func uiObjFadeIn(objName string, duration uint32) (bool, error) {
}
func uiObjFadeOut(objName string, duration uint32) (bool, error) {
+ if cgoDisabled {
+ return false, nil
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -313,6 +410,10 @@ func uiObjFadeOut(objName string, duration uint32) (bool, error) {
}
func uiLabelSetText(objName string, text string) (bool, error) {
+ if cgoDisabled {
+ return false, nil
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -330,6 +431,10 @@ func uiLabelSetText(objName string, text string) (bool, error) {
}
func uiImgSetSrc(objName string, src string) (bool, error) {
+ if cgoDisabled {
+ return false, nil
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -345,6 +450,10 @@ func uiImgSetSrc(objName string, src string) (bool, error) {
}
func uiDispSetRotation(rotation uint16) (bool, error) {
+ if cgoDisabled {
+ return false, nil
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -357,6 +466,10 @@ func uiDispSetRotation(rotation uint16) (bool, error) {
}
func videoGetStreamQualityFactor() (float64, error) {
+ if cgoDisabled {
+ return 0, nil
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -365,6 +478,10 @@ func videoGetStreamQualityFactor() (float64, error) {
}
func videoSetStreamQualityFactor(factor float64) error {
+ if cgoDisabled {
+ return nil
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -373,6 +490,10 @@ func videoSetStreamQualityFactor(factor float64) error {
}
func videoGetEDID() (string, error) {
+ if cgoDisabled {
+ return "", nil
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
@@ -381,6 +502,10 @@ func videoGetEDID() (string, error) {
}
func videoSetEDID(edid string) error {
+ if cgoDisabled {
+ return nil
+ }
+
cgoLock.Lock()
defer cgoLock.Unlock()
diff --git a/internal/native/native.go b/internal/native/native.go
index 2a9055ce..0a773246 100644
--- a/internal/native/native.go
+++ b/internal/native/native.go
@@ -9,6 +9,7 @@ import (
)
type Native struct {
+ disable bool
ready chan struct{}
l *zerolog.Logger
lD *zerolog.Logger
@@ -27,6 +28,7 @@ type Native struct {
}
type NativeOptions struct {
+ Disable bool
SystemVersion *semver.Version
AppVersion *semver.Version
DisplayRotation uint16
@@ -74,6 +76,7 @@ func NewNative(opts NativeOptions) *Native {
}
return &Native{
+ disable: opts.Disable,
ready: make(chan struct{}),
l: nativeLogger,
lD: displayLogger,
@@ -92,6 +95,12 @@ func NewNative(opts NativeOptions) *Native {
}
func (n *Native) Start() {
+ if n.disable {
+ nativeLogger.Warn().Msg("native is disabled, skipping initialization")
+ setCgoDisabled(true)
+ return
+ }
+
// set up singleton
setInstance(n)
setUpNativeHandlers()
diff --git a/jsonrpc.go b/jsonrpc.go
index 99e7bdcf..ea6c6fba 100644
--- a/jsonrpc.go
+++ b/jsonrpc.go
@@ -1268,4 +1268,5 @@ var rpcHandlers = map[string]RPCHandler{
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
+ "getFailSafeLogs": {Func: rpcGetFailsafeLogs},
}
diff --git a/log.go b/log.go
index 2047bbfa..9cd9188e 100644
--- a/log.go
+++ b/log.go
@@ -11,6 +11,7 @@ func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error {
var (
logger = logging.GetSubsystemLogger("jetkvm")
+ failsafeLogger = logging.GetSubsystemLogger("failsafe")
networkLogger = logging.GetSubsystemLogger("network")
cloudLogger = logging.GetSubsystemLogger("cloud")
websocketLogger = logging.GetSubsystemLogger("websocket")
diff --git a/main.go b/main.go
index 81c85431..669c6f44 100644
--- a/main.go
+++ b/main.go
@@ -14,6 +14,11 @@ import (
var appCtx context.Context
func Main() {
+ checkFailsafeReason()
+ if failsafeModeActive {
+ logger.Warn().Str("reason", failsafeModeReason).Msg("failsafe mode activated")
+ }
+
LoadConfig()
var cancel context.CancelFunc
@@ -48,6 +53,7 @@ func Main() {
// Initialize network
if err := initNetwork(); err != nil {
logger.Error().Err(err).Msg("failed to initialize network")
+ // TODO: reset config to default
os.Exit(1)
}
@@ -58,7 +64,6 @@ func Main() {
// Initialize mDNS
if err := initMdns(); err != nil {
logger.Error().Err(err).Msg("failed to initialize mDNS")
- os.Exit(1)
}
initPrometheus()
diff --git a/native.go b/native.go
index d7c85b0b..c42cad3c 100644
--- a/native.go
+++ b/native.go
@@ -17,6 +17,7 @@ var (
func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
nativeInstance = native.NewNative(native.NativeOptions{
+ Disable: failsafeModeActive,
SystemVersion: systemVersion,
AppVersion: appVersion,
DisplayRotation: config.GetDisplayRotation(),
diff --git a/ui/src/components/FailSafeModeBanner.tsx b/ui/src/components/FailSafeModeBanner.tsx
new file mode 100644
index 00000000..04fddc67
--- /dev/null
+++ b/ui/src/components/FailSafeModeBanner.tsx
@@ -0,0 +1,30 @@
+import { LuTriangleAlert } from "react-icons/lu";
+
+import Card from "@components/Card";
+
+interface FailsafeModeBannerProps {
+ reason: string;
+}
+
+export function FailsafeModeBanner({ reason }: FailsafeModeBannerProps) {
+ const getReasonMessage = () => {
+ switch (reason) {
+ case "video":
+ return "Failsafe Mode Active: Video-related settings are currently unavailable";
+ default:
+ return "Failsafe Mode Active: Some settings may be unavailable";
+ }
+ };
+
+ return (
+
+ {getReasonMessage()}
+
{message}
+