mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-15 11:19:07 +02:00
fix revue de code : reset pendant generation (epoch guard, plus de messages fantomes), message user persiste des l'envoi, cle de pilotage refusee en query string (fuite logs/historique), verif SHA256SUMS dans jean update, edit preserve les permissions du fichier, conflit de port gere sous Windows (netstat/taskkill), /api/update/apply renvoie 500 en echec
This commit is contained in:
1 parent
cd570c5c5e
commit
02d164899e
6 files changed
+213
-60
No files matched your search
@@ -101,8 +101,15 @@ func (c *Conversation) persist() {
|
||||
}
|
||||
|
||||
// appendDelta journalise un événement d'affichage et réveille les abonnés.
|
||||
func (c *Conversation) appendDelta(delta map[string]any) {
|
||||
// epoch est celui capturé au début du tour : si un Reset est passé entre-temps,
|
||||
// l'événement appartient à l'ancienne conversation et est jeté (sinon il
|
||||
// polluerait le journal tout neuf avec des Seq repartis de zéro).
|
||||
func (c *Conversation) appendDelta(epoch int, delta map[string]any) {
|
||||
c.mu.Lock()
|
||||
if c.epoch != epoch {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.Seq++
|
||||
c.Log = append(c.Log, LogEvent{Seq: c.Seq, TS: time.Now().UnixMilli(), Delta: delta})
|
||||
if len(c.Log) > maxLogEvents {
|
||||
@@ -138,26 +145,37 @@ func (c *Conversation) StartTurn(text string, caps Caps, temperature float64) er
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.cancel = cancel
|
||||
c.Messages = append(c.Messages, Message{Role: "user", Content: text})
|
||||
epoch := c.epoch
|
||||
c.mu.Unlock()
|
||||
|
||||
// Borne de tour + bulle utilisateur (rejouables).
|
||||
c.appendDelta(map[string]any{"user": text})
|
||||
// Borne de tour + bulle utilisateur (rejouables). Persistée tout de suite :
|
||||
// si le process meurt en pleine génération (crash, restart après MAJ), le
|
||||
// message de l'utilisateur survit au lieu de disparaître avec le tour.
|
||||
c.appendDelta(epoch, map[string]any{"user": text})
|
||||
c.persist()
|
||||
if temperature == 0 {
|
||||
temperature = 0.7
|
||||
}
|
||||
go c.generate(ctx, caps, temperature)
|
||||
go c.generate(ctx, caps, temperature, epoch)
|
||||
return nil
|
||||
}
|
||||
|
||||
// generate exécute un tour complet et journalise chaque événement. Détaché : la
|
||||
// fermeture du navigateur n'a aucun effet ici, seul /stop (cancel) l'interrompt.
|
||||
func (c *Conversation) generate(ctx context.Context, caps Caps, temperature float64) {
|
||||
// epoch est capturé au StartTurn : si un Reset survient pendant la génération,
|
||||
// tout ce que ce tour produirait ensuite (deltas, messages, persistance) est
|
||||
// abandonné au lieu de ressusciter des morceaux de l'ancienne conversation.
|
||||
func (c *Conversation) generate(ctx context.Context, caps Caps, temperature float64, epoch int) {
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
c.Generating = false
|
||||
c.cancel = nil
|
||||
stale := c.epoch != epoch
|
||||
c.mu.Unlock()
|
||||
c.appendDelta(map[string]any{"turn_done": true})
|
||||
if stale {
|
||||
return // Reset pendant le tour : Reset a déjà persisté l'état vide
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"turn_done": true})
|
||||
c.persist()
|
||||
}()
|
||||
|
||||
@@ -172,9 +190,11 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
if compacted, changed := MaybeCompact(ctx, msgs, caps, ctxUsed); changed {
|
||||
msgs = compacted
|
||||
c.mu.Lock()
|
||||
c.Messages = compacted
|
||||
if c.epoch == epoch {
|
||||
c.Messages = compacted
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.appendDelta(map[string]any{"compacted": true})
|
||||
c.appendDelta(epoch, map[string]any{"compacted": true})
|
||||
}
|
||||
|
||||
// Prompt système personnalisé (UI → /api/sysprompt, fichier côté serveur).
|
||||
@@ -189,9 +209,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
c.appendDelta(map[string]any{"error": ev.Err.Error()})
|
||||
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
|
||||
case ev.ToolUsed != nil:
|
||||
c.appendDelta(map[string]any{"tool_used": map[string]any{
|
||||
c.appendDelta(epoch, map[string]any{"tool_used": map[string]any{
|
||||
"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
|
||||
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
|
||||
}})
|
||||
@@ -200,28 +220,33 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// et la décision de compactage au tour suivant.
|
||||
if ev.Stats.PromptTokensTotal > 0 {
|
||||
c.mu.Lock()
|
||||
c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
|
||||
if c.epoch == epoch {
|
||||
c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
c.appendDelta(map[string]any{"stats": ev.Stats})
|
||||
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
|
||||
case ev.DropReasoning:
|
||||
c.appendDelta(map[string]any{"drop_reasoning": true})
|
||||
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
|
||||
case ev.Reasoning != "":
|
||||
c.appendDelta(map[string]any{"reasoning_content": ev.Reasoning})
|
||||
c.appendDelta(epoch, map[string]any{"reasoning_content": ev.Reasoning})
|
||||
case ev.Content != "":
|
||||
content.WriteString(ev.Content)
|
||||
c.appendDelta(map[string]any{"content": ev.Content})
|
||||
c.appendDelta(epoch, map[string]any{"content": ev.Content})
|
||||
}
|
||||
return true // génération détachée : on ne s'interrompt jamais sur un abonné
|
||||
})
|
||||
|
||||
// Persiste la vue modèle : messages d'outils (assistant tool_calls + résultats)
|
||||
// PUIS la réponse finale — même ordre que l'ancien client, pour que le modèle
|
||||
// garde la trace de ce qu'il a fait.
|
||||
// garde la trace de ce qu'il a fait. Sauf si un Reset est passé entre-temps :
|
||||
// la nouvelle conversation vide ne doit pas hériter de la fin de l'ancienne.
|
||||
c.mu.Lock()
|
||||
c.Messages = append(c.Messages, extra...)
|
||||
if s := content.String(); strings.TrimSpace(s) != "" {
|
||||
c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
|
||||
if c.epoch == epoch {
|
||||
c.Messages = append(c.Messages, extra...)
|
||||
if s := content.String(); strings.TrimSpace(s) != "" {
|
||||
c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
@@ -407,32 +432,30 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
|
||||
c.mu.Lock()
|
||||
continue
|
||||
}
|
||||
sent := false
|
||||
// Copie des événements en attente SOUS verrou, émission HORS verrou : on
|
||||
// n'itère jamais sur c.Log pendant que la génération peut y écrire ou que
|
||||
// la troncature (maxLogEvents) peut le déplacer.
|
||||
var pending []LogEvent
|
||||
for _, ev := range c.Log {
|
||||
if ev.Seq <= last {
|
||||
continue
|
||||
if ev.Seq > last {
|
||||
pending = append(pending, ev)
|
||||
}
|
||||
last = ev.Seq
|
||||
delta := ev.Delta
|
||||
ts := ev.TS
|
||||
c.mu.Unlock()
|
||||
out := map[string]any{"seq": ev.Seq, "ts": ts}
|
||||
for k, v := range delta {
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
c.cond.Wait()
|
||||
continue
|
||||
}
|
||||
last = pending[len(pending)-1].Seq
|
||||
c.mu.Unlock()
|
||||
for _, ev := range pending {
|
||||
out := map[string]any{"seq": ev.Seq, "ts": ev.TS}
|
||||
for k, v := range ev.Delta {
|
||||
out[k] = v
|
||||
}
|
||||
if !emit(out) {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
sent = true
|
||||
if ctx.Err() != nil {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
if sent {
|
||||
continue
|
||||
}
|
||||
c.cond.Wait()
|
||||
c.mu.Lock()
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,8 @@ func newTestConv() *Conversation {
|
||||
// et se terminer quand le contexte (la connexion) est annulé.
|
||||
func TestSubscribeReplayAndLive(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.appendDelta(map[string]any{"user": "salut"})
|
||||
c.appendDelta(map[string]any{"content": "bon"})
|
||||
c.appendDelta(c.epoch, map[string]any{"user": "salut"})
|
||||
c.appendDelta(c.epoch, map[string]any{"content": "bon"})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
@@ -38,15 +38,15 @@ func TestSubscribeReplayAndLive(t *testing.T) {
|
||||
waitSeq(t, got, 1)
|
||||
waitSeq(t, got, 2)
|
||||
// Un événement en direct après abonnement.
|
||||
c.appendDelta(map[string]any{"content": "jour"})
|
||||
c.appendDelta(c.epoch, map[string]any{"content": "jour"})
|
||||
waitSeq(t, got, 3)
|
||||
}
|
||||
|
||||
// Un abonné qui démarre à from=N ne reçoit que ce qui est plus récent que N.
|
||||
func TestSubscribeFromOffset(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.appendDelta(map[string]any{"user": "a"})
|
||||
c.appendDelta(map[string]any{"user": "b"})
|
||||
c.appendDelta(c.epoch, map[string]any{"user": "a"})
|
||||
c.appendDelta(c.epoch, map[string]any{"user": "b"})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
@@ -64,7 +64,7 @@ func TestSubscribeFromOffset(t *testing.T) {
|
||||
// Reset bump l'epoch et pousse un {reset:true} aux abonnés, qui repartent de 0.
|
||||
func TestResetNotifiesSubscribers(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.appendDelta(map[string]any{"user": "x"})
|
||||
c.appendDelta(c.epoch, map[string]any{"user": "x"})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
resetSeen := make(chan bool, 4)
|
||||
@@ -87,6 +87,24 @@ func TestResetNotifiesSubscribers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Un Reset survenu pendant un tour invalide l'epoch : les deltas du tour en
|
||||
// cours sont jetés au lieu de polluer la nouvelle conversation (Seq repartis
|
||||
// de zéro, messages fantômes).
|
||||
func TestAppendDeltaStaleEpochDropped(t *testing.T) {
|
||||
c := newTestConv()
|
||||
epoch := c.epoch // capturé comme au début d'un tour
|
||||
c.appendDelta(epoch, map[string]any{"user": "avant"})
|
||||
c.Reset()
|
||||
c.appendDelta(epoch, map[string]any{"content": "fantôme"}) // tour périmé
|
||||
if c.Seq != 0 || len(c.Log) != 0 {
|
||||
t.Fatalf("delta périmé accepté après Reset: Seq=%d len(Log)=%d", c.Seq, len(c.Log))
|
||||
}
|
||||
c.appendDelta(c.epoch, map[string]any{"user": "nouveau"}) // nouveau tour
|
||||
if c.Seq != 1 || len(c.Log) != 1 {
|
||||
t.Fatalf("delta du nouvel epoch refusé: Seq=%d len(Log)=%d", c.Seq, len(c.Log))
|
||||
}
|
||||
}
|
||||
|
||||
// Les handlers de contrôle répondent en JSON sans dépendre du modèle.
|
||||
func TestChatControlHandlers(t *testing.T) {
|
||||
// reset → conversation vide
|
||||
|
||||
@@ -180,7 +180,12 @@ func fileEdit(path, oldText, newText string) string {
|
||||
return fmt.Sprintf("[erreur] old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
|
||||
}
|
||||
updated := strings.Replace(content, oldText, newText, 1)
|
||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
||||
// Préserve les permissions d'origine (un script 0755 doit rester exécutable).
|
||||
mode := os.FileMode(0o644)
|
||||
if fi, err := os.Stat(path); err == nil {
|
||||
mode = fi.Mode()
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(updated), mode); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return fmt.Sprintf("[ok] %s modifié (1 remplacement)", path)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -137,6 +139,10 @@ func applyUpdate() (string, error) {
|
||||
if err := downloadTo(url, tmp); err != nil {
|
||||
return "", fmt.Errorf("téléchargement : %w", err)
|
||||
}
|
||||
if err := verifyChecksum(rel, want, tmp); err != nil {
|
||||
os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
mode := os.FileMode(0o755)
|
||||
if fi, err := os.Stat(exe); err == nil {
|
||||
mode = fi.Mode()
|
||||
@@ -214,6 +220,63 @@ func downloadTo(url, dst string) error {
|
||||
return cErr
|
||||
}
|
||||
|
||||
// verifyChecksum vérifie le SHA-256 du binaire téléchargé contre le fichier
|
||||
// SHA256SUMS publié dans la release (format `sha256sum` : "<hex> <nom>").
|
||||
// Si la release n'en publie pas (anciennes versions), on ne vérifie rien —
|
||||
// le contrôle de taille reste le seul garde-fou, comme avant.
|
||||
func verifyChecksum(rel *ghRelease, assetName, path string) error {
|
||||
var sumsURL string
|
||||
for _, a := range rel.Assets {
|
||||
switch a.Name {
|
||||
case "SHA256SUMS", "SHA256SUMS.txt", "checksums.txt":
|
||||
sumsURL = a.BrowserDownloadURL
|
||||
}
|
||||
}
|
||||
if sumsURL == "" {
|
||||
return nil
|
||||
}
|
||||
req, _ := http.NewRequest("GET", sumsURL, nil)
|
||||
req.Header.Set("User-Agent", "jean-update")
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("téléchargement des sommes de contrôle : %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("sommes de contrôle : GitHub a répondu %s", resp.Status)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
want := ""
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
f := strings.Fields(line)
|
||||
// sha256sum peut préfixer le nom de "*" (mode binaire).
|
||||
if len(f) == 2 && strings.TrimPrefix(f[1], "*") == assetName {
|
||||
want = strings.ToLower(f[0])
|
||||
break
|
||||
}
|
||||
}
|
||||
if want == "" {
|
||||
return fmt.Errorf("SHA256SUMS présent mais sans entrée pour %q — mise à jour annulée", assetName)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return err
|
||||
}
|
||||
got := hex.EncodeToString(h.Sum(nil))
|
||||
if got != want {
|
||||
return fmt.Errorf("somme SHA-256 invalide (%s reçu, %s attendu) — mise à jour annulée", got, want)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileSize(p string) int64 {
|
||||
if fi, err := os.Stat(p); err == nil {
|
||||
return fi.Size()
|
||||
@@ -270,7 +333,8 @@ func handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
|
||||
func handleUpdateApply(w http.ResponseWriter, r *http.Request) {
|
||||
newVer, err := applyUpdate()
|
||||
if err != nil {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
|
||||
// 500 : un client script peut tester le statut HTTP ; l'UI, elle, lit le JSON.
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
restarting, msg := restartAfterUpdate()
|
||||
|
||||
@@ -53,10 +53,10 @@ func requireWebAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// checkBearer reports whether the request carries the expected key, either as an
|
||||
// "Authorization: Bearer <clé>" header (cas normal) or as a ?key=<clé>
|
||||
// query param (repli pour les clients qui ne peuvent pas poser d'en-tête, p.ex.
|
||||
// une URL ouverte directement). La comparaison est à temps constant.
|
||||
// checkBearer reports whether the request carries the expected key as an
|
||||
// "Authorization: Bearer <clé>" header. La comparaison est à temps constant.
|
||||
// PAS de repli ?key=<clé> en query string : une clé dans l'URL finit dans les
|
||||
// logs des proxys (Caddy, Cloudflare), l'historique navigateur et les Referer.
|
||||
func checkBearer(r *http.Request, key string) bool {
|
||||
want := []byte(key)
|
||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
@@ -65,11 +65,6 @@ func checkBearer(r *http.Request, key string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if q := r.URL.Query().Get("key"); q != "" {
|
||||
if subtle.ConstantTimeCompare([]byte(q), want) == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -142,8 +143,8 @@ func resolvePortConflict(port int) bool {
|
||||
fmt.Println(dim(" annulé."))
|
||||
return false
|
||||
}
|
||||
// SIGTERM d'abord, puis SIGKILL si le port ne se libère pas.
|
||||
_ = exec.Command("kill", "-TERM", strconv.Itoa(pid)).Run()
|
||||
// Arrêt poli d'abord, puis forcé si le port ne se libère pas.
|
||||
killPid(pid, false)
|
||||
for i := 0; i < 15; i++ {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if p, _ := pidOnPort(port); p == 0 {
|
||||
@@ -151,7 +152,7 @@ func resolvePortConflict(port int) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
_ = exec.Command("kill", "-KILL", strconv.Itoa(pid)).Run()
|
||||
killPid(pid, true)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if p, _ := pidOnPort(port); p != 0 {
|
||||
fmt.Printf("%s impossible de libérer le port %d (PID %d toujours présent)\n", red("[err]"), port, p)
|
||||
@@ -161,10 +162,47 @@ func resolvePortConflict(port int) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// killPid termine un process : kill TERM/KILL sous Unix, taskkill sous Windows
|
||||
// (où il n'existe pas d'arrêt « poli » générique — taskkill sans /F échoue sur
|
||||
// les process console, donc le second essai passe en forcé).
|
||||
func killPid(pid int, force bool) {
|
||||
if runtime.GOOS == "windows" {
|
||||
args := []string{"/PID", strconv.Itoa(pid)}
|
||||
if force {
|
||||
args = append(args, "/F")
|
||||
}
|
||||
_ = exec.Command("taskkill", args...).Run()
|
||||
return
|
||||
}
|
||||
sig := "-TERM"
|
||||
if force {
|
||||
sig = "-KILL"
|
||||
}
|
||||
_ = exec.Command("kill", sig, strconv.Itoa(pid)).Run()
|
||||
}
|
||||
|
||||
// pidOnPort returns the PID and command name of the process listening on the
|
||||
// given TCP port, via `ss` (Linux) with an `lsof` fallback. Returns 0 if none
|
||||
// is found or if the tools can't see it (e.g. owned by another user).
|
||||
// given TCP port, via `ss` (Linux) with an `lsof` fallback, or `netstat -ano`
|
||||
// on Windows. Returns 0 if none is found or if the tools can't see it (e.g.
|
||||
// owned by another user).
|
||||
func pidOnPort(port int) (int, string) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// netstat -ano : " TCP 0.0.0.0:8090 0.0.0.0:0 LISTENING 1234"
|
||||
out, err := exec.Command("netstat", "-ano", "-p", "tcp").Output()
|
||||
if err != nil {
|
||||
return 0, ""
|
||||
}
|
||||
suffix := ":" + strconv.Itoa(port)
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) >= 5 && f[0] == "TCP" && strings.HasSuffix(f[1], suffix) && f[3] == "LISTENING" {
|
||||
if pid, err := strconv.Atoi(f[4]); err == nil && pid > 0 {
|
||||
return pid, processName(pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
redir := regexp.MustCompile(`pid=(\d+)`)
|
||||
if out, err := exec.Command("ss", "-ltnHp", fmt.Sprintf("sport = :%d", port)).Output(); err == nil {
|
||||
if m := redir.FindStringSubmatch(string(out)); m != nil {
|
||||
@@ -184,6 +222,16 @@ func pidOnPort(port int) (int, string) {
|
||||
|
||||
// processName returns a short command name for a PID, or "?" if unknown.
|
||||
func processName(pid int) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
// tasklist CSV : "jean.exe","1234","Console","1","12 345 K"
|
||||
out, err := exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH").Output()
|
||||
if err == nil {
|
||||
if f := strings.SplitN(strings.TrimSpace(string(out)), "\",\"", 2); len(f) == 2 {
|
||||
return strings.TrimPrefix(f[0], "\"")
|
||||
}
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
if b, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)); err == nil {
|
||||
if n := strings.TrimSpace(string(b)); n != "" {
|
||||
return n
|
||||
|
||||
Reference in new issue
Block a user