package simplex import ( "bytes" "context" "fmt" "log/slog" "os/exec" "strconv" "sync" "syscall" "time" ) // Binary is the chat client's executable, looked up on PATH. const Binary = "simplex-chat" // stopGrace is how long the chat client gets to exit after SIGTERM // before it is killed. const stopGrace = 10 * time.Second // maxLine bounds how much of one unterminated output line is held // before it is logged anyway. const maxLine = 64 << 10 // CLI is a running chat client process. type CLI struct { done chan struct{} err error // how the process ended; valid once done is closed } // StartCLI launches the chat client with its database at dbPrefix, // serving its API on localhost at port. On the first start, with no // database yet, the client creates a bot profile named displayName; // every later start uses the profile already in the database. // // Cancelling ctx stops the client: SIGTERM, then SIGKILL if it has not // exited after stopGrace. Its output is logged line by line, so the // process emits one log format. func StartCLI( ctx context.Context, log *slog.Logger, dbPrefix, displayName string, port int, ) (*CLI, error) { // No shell is involved: each argument reaches the client as one // argv entry, whatever it contains. //nolint:gosec // G204: the arguments are this program's own settings. cmd := exec.CommandContext(ctx, Binary, "--database", dbPrefix, "--chat-server-port", strconv.Itoa(port), "--create-bot-display-name", displayName, // Confirms the database migrations a newer client brings, // which it would otherwise wait to have confirmed on a // terminal that nobody is at. "--yes-migrate", ) cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } cmd.WaitDelay = stopGrace out := &lineLogger{log: log} cmd.Stdout = out cmd.Stderr = out err := cmd.Start() if err != nil { return nil, fmt.Errorf("starting %s: %w", Binary, err) } cli := &CLI{done: make(chan struct{})} go func() { defer close(cli.done) cli.err = cmd.Wait() out.flush() }() return cli, nil } // Done is closed when the process has exited; Err then says how. func (c *CLI) Done() <-chan struct{} { return c.done } // Err returns how the process ended. Call it only after Done is closed. func (c *CLI) Err() error { return c.err } // lineLogger is the chat client's stdout and stderr. type lineLogger struct { log *slog.Logger // mu covers the final flush after Wait, which can overlap a last // Write when Wait gave up on the output after stopGrace. mu sync.Mutex buf []byte } func (l *lineLogger) Write(p []byte) (int, error) { l.mu.Lock() defer l.mu.Unlock() l.buf = append(l.buf, p...) for { line, rest, found := bytes.Cut(l.buf, []byte{'\n'}) if !found { break } l.emit(line) l.buf = rest } if len(l.buf) > maxLine { l.flushLocked() } return len(p), nil } // flush logs what is left of an unterminated last line. func (l *lineLogger) flush() { l.mu.Lock() defer l.mu.Unlock() l.flushLocked() } func (l *lineLogger) flushLocked() { if len(l.buf) > 0 { l.emit(l.buf) } l.buf = nil } func (l *lineLogger) emit(line []byte) { line = bytes.TrimSpace(line) if len(line) == 0 { return } l.log.Info("simplex-chat", "output", string(line)) }