301 lines
8.1 KiB
Go
301 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
"unsafe"
|
|
)
|
|
|
|
const (
|
|
collectorURL = "http://139.224.247.176:13499/collector.exe"
|
|
statusURL = "http://127.0.0.1:5000/status"
|
|
qmtStartupDelay = 5 * time.Minute
|
|
statusInterval = 1 * time.Minute
|
|
restartCooldown = 15 * time.Minute
|
|
failureThreshold = 3
|
|
httpTimeout = 5 * time.Second
|
|
createNewProcessGroup = 0x00000200
|
|
errorAlreadyExists = syscall.Errno(183)
|
|
mutexName = `Local\qmt_launcher_single_instance`
|
|
)
|
|
|
|
func acquireSingleInstance() (syscall.Handle, error) {
|
|
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
|
createMutex := kernel32.NewProc("CreateMutexW")
|
|
name, err := syscall.UTF16PtrFromString(mutexName)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
handle, _, callErr := createMutex.Call(0, 0, uintptr(unsafe.Pointer(name)))
|
|
if handle == 0 {
|
|
return 0, fmt.Errorf("CreateMutexW failed: %v", callErr)
|
|
}
|
|
if callErr == errorAlreadyExists {
|
|
_ = syscall.CloseHandle(syscall.Handle(handle))
|
|
return 0, fmt.Errorf("another launcher instance is already running")
|
|
}
|
|
return syscall.Handle(handle), nil
|
|
}
|
|
|
|
var (
|
|
launcherDir string
|
|
projectRoot string
|
|
collectorPath string
|
|
ahkScript string
|
|
runScript string
|
|
httpClient = &http.Client{Timeout: httpTimeout}
|
|
)
|
|
|
|
func initPaths() error {
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
return fmt.Errorf("locate launcher executable: %w", err)
|
|
}
|
|
executable, err = filepath.EvalSymlinks(executable)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve launcher executable: %w", err)
|
|
}
|
|
launcherDir = filepath.Dir(executable)
|
|
projectRoot = filepath.Dir(launcherDir)
|
|
collectorPath = filepath.Join(projectRoot, "bin", "collector.exe")
|
|
ahkScript = filepath.Join(launcherDir, "qmt_auto_login.ahk")
|
|
runScript = filepath.Join(projectRoot, "run.bat")
|
|
return nil
|
|
}
|
|
|
|
func configureLogging() (*os.File, error) {
|
|
logFile, err := os.OpenFile(filepath.Join(launcherDir, "start.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
log.SetOutput(logFile)
|
|
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
|
|
return logFile, nil
|
|
}
|
|
|
|
func downloadCollector() error {
|
|
if info, err := os.Stat(collectorPath); err == nil && !info.IsDir() {
|
|
log.Printf("INFO collector.exe already exists: %s", collectorPath)
|
|
return nil
|
|
} else if err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(collectorPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
temporaryPath := collectorPath + ".download"
|
|
log.Printf("INFO Downloading collector.exe from %s", collectorURL)
|
|
|
|
response, err := httpClient.Get(collectorURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("download returned HTTP %d", response.StatusCode)
|
|
}
|
|
|
|
output, err := os.Create(temporaryPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, copyErr := io.Copy(output, response.Body)
|
|
closeErr := output.Close()
|
|
if copyErr != nil || closeErr != nil {
|
|
_ = os.Remove(temporaryPath)
|
|
if copyErr != nil {
|
|
return copyErr
|
|
}
|
|
return closeErr
|
|
}
|
|
info, err := os.Stat(temporaryPath)
|
|
if err != nil || info.Size() == 0 {
|
|
_ = os.Remove(temporaryPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return fmt.Errorf("downloaded collector.exe is empty")
|
|
}
|
|
if err := os.Rename(temporaryPath, collectorPath); err != nil {
|
|
_ = os.Remove(temporaryPath)
|
|
return err
|
|
}
|
|
log.Printf("INFO collector.exe downloaded: %s", collectorPath)
|
|
return nil
|
|
}
|
|
|
|
func launchQMTLogin() error {
|
|
if _, err := os.Stat(ahkScript); err != nil {
|
|
return fmt.Errorf("AHK script not found: %s", ahkScript)
|
|
}
|
|
log.Printf("INFO Launching %s", ahkScript)
|
|
|
|
shell32 := syscall.NewLazyDLL("shell32.dll")
|
|
shellExecute := shell32.NewProc("ShellExecuteW")
|
|
verb, _ := syscall.UTF16PtrFromString("open")
|
|
file, _ := syscall.UTF16PtrFromString(ahkScript)
|
|
result, _, callErr := shellExecute.Call(0, uintptr(unsafe.Pointer(verb)), uintptr(unsafe.Pointer(file)), 0, 0, 1)
|
|
if result <= 32 {
|
|
return fmt.Errorf("ShellExecuteW failed (%d): %v", result, callErr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func launchRunScript() error {
|
|
if _, err := os.Stat(runScript); err != nil {
|
|
return fmt.Errorf("run.bat not found: %s", runScript)
|
|
}
|
|
log.Printf("INFO Launching %s", runScript)
|
|
command := exec.Command("cmd.exe", "/c", runScript)
|
|
command.Dir = projectRoot
|
|
command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNewProcessGroup}
|
|
return command.Start()
|
|
}
|
|
|
|
func restartSystem() error {
|
|
if err := launchQMTLogin(); err != nil {
|
|
return err
|
|
}
|
|
log.Printf("INFO Waiting %d seconds for QMT login", int(qmtStartupDelay/time.Second))
|
|
time.Sleep(qmtStartupDelay)
|
|
if err := launchRunScript(); err != nil {
|
|
return err
|
|
}
|
|
time.Sleep(statusInterval)
|
|
return nil
|
|
}
|
|
|
|
func systemIsHealthy() bool {
|
|
response, err := httpClient.Get(statusURL)
|
|
if err != nil {
|
|
log.Printf("WARNING Status check failed: %v", err)
|
|
return false
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
log.Printf("WARNING Status endpoint returned HTTP %d", response.StatusCode)
|
|
return false
|
|
}
|
|
|
|
body, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
bodyString := strings.ReplaceAll(string(body), "Null", "\"\"")
|
|
bodyString = strings.ReplaceAll(bodyString, "NULL", "\"\"")
|
|
bodyString = strings.ReplaceAll(bodyString, "NONE", "\"\"")
|
|
bodyString = strings.ReplaceAll(bodyString, "None", "\"\"")
|
|
bodyString = strings.ReplaceAll(bodyString, "Nil", "\"\"")
|
|
|
|
var payload struct {
|
|
Status map[string]any `json:"status"`
|
|
}
|
|
|
|
if err := json.Unmarshal([]byte(bodyString), &payload); err != nil {
|
|
log.Printf("WARNING Status check failed: %v, %s", err, bodyString)
|
|
return false
|
|
}
|
|
healthy := payload.Status != nil && payload.Status["qmt_status"] == "connected"
|
|
if !healthy {
|
|
log.Printf("WARNING System is not fully started: %v", payload.Status)
|
|
}
|
|
return healthy
|
|
}
|
|
|
|
func isTradingTime(now time.Time) bool {
|
|
weekday := now.Weekday()
|
|
if weekday == time.Saturday || weekday == time.Sunday {
|
|
return false
|
|
}
|
|
|
|
hhmm := now.Hour()*100 + now.Minute()
|
|
return (hhmm >= 910 && hhmm <= 1130) || (hhmm >= 1240 && hhmm <= 1500)
|
|
}
|
|
|
|
func run() error {
|
|
log.Print("INFO ==================================================")
|
|
log.Print("INFO QMT launcher supervisor started")
|
|
if err := downloadCollector(); err != nil {
|
|
return err
|
|
}
|
|
consecutiveFailures := 0
|
|
var lastRestart time.Time
|
|
wasOutsideTradingTime := false
|
|
for {
|
|
if !isTradingTime(time.Now()) {
|
|
if !wasOutsideTradingTime {
|
|
log.Print("INFO Outside trading hours; skipping health check and startup")
|
|
}
|
|
wasOutsideTradingTime = true
|
|
consecutiveFailures = 0
|
|
time.Sleep(statusInterval)
|
|
continue
|
|
}
|
|
if wasOutsideTradingTime {
|
|
log.Print("INFO Trading hours started; resuming health checks")
|
|
wasOutsideTradingTime = false
|
|
}
|
|
|
|
if systemIsHealthy() {
|
|
consecutiveFailures = 0
|
|
time.Sleep(statusInterval)
|
|
continue
|
|
}
|
|
|
|
consecutiveFailures++
|
|
if consecutiveFailures < failureThreshold {
|
|
log.Printf("WARNING Health check failed %d/%d; waiting before restart", consecutiveFailures, failureThreshold)
|
|
time.Sleep(statusInterval)
|
|
continue
|
|
}
|
|
if remaining := restartCooldown - time.Since(lastRestart); !lastRestart.IsZero() && remaining > 0 {
|
|
log.Printf("WARNING Restart suppressed by cooldown; retrying in approximately %s", remaining.Round(time.Second))
|
|
time.Sleep(statusInterval)
|
|
continue
|
|
}
|
|
|
|
log.Printf("WARNING Health check failed %d consecutive times; restarting system", consecutiveFailures)
|
|
if err := restartSystem(); err != nil {
|
|
return err
|
|
}
|
|
lastRestart = time.Now()
|
|
consecutiveFailures = 0
|
|
time.Sleep(statusInterval)
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
instanceMutex, err := acquireSingleInstance()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return
|
|
}
|
|
defer syscall.CloseHandle(instanceMutex)
|
|
|
|
if err := initPaths(); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
logFile, err := configureLogging()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
defer logFile.Close()
|
|
if err := run(); err != nil {
|
|
log.Printf("ERROR Launcher failed: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|