chore: auto-commit (95 archivos)
- cmd/fn/doctor.go - cmd/fn/main.go - cpp/apps/primitives_gallery/playground/tables/CMakeLists.txt - cpp/apps/primitives_gallery/playground/tables/data_table.cpp - cpp/apps/primitives_gallery/playground/tables/data_table_logic.cpp - cpp/apps/primitives_gallery/playground/tables/data_table_logic.h - cpp/apps/primitives_gallery/playground/tables/self_test.cpp - cpp/apps/primitives_gallery/playground/tables/tql.cpp - cpp/apps/primitives_gallery/playground/tables/viz.cpp - cpp/apps/primitives_gallery/playground/tables/viz.h - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StreamEvent es una linea capturada de stdout o stderr del subproceso.
|
||||
type StreamEvent struct {
|
||||
Stream string // "stdout" | "stderr"
|
||||
Line string // sin trailing newline
|
||||
Time time.Time // timestamp de recepcion
|
||||
}
|
||||
|
||||
// StreamResult es el resultado final del subproceso, enviado por el canal de
|
||||
// resultados cuando ambos pipes han llegado a EOF y el proceso ha terminado.
|
||||
type StreamResult struct {
|
||||
ExitCode int
|
||||
Err error
|
||||
DurationMs int64
|
||||
}
|
||||
|
||||
// SubprocessStream lanza name con args como subproceso y retorna dos canales:
|
||||
// - events: recibe StreamEvent (linea de stdout/stderr) hasta EOF de ambos pipes.
|
||||
// - result: recibe exactamente un StreamResult cuando el proceso termina.
|
||||
//
|
||||
// env se concatena con os.Environ(). stdin puede ser nil.
|
||||
//
|
||||
// Cancelar ctx envia SIGTERM al proceso; si no termina en 2 segundos, SIGKILL.
|
||||
// El caller DEBE consumir events hasta que se cierre o cancelar ctx para evitar
|
||||
// bloquear las goroutines internas.
|
||||
func SubprocessStream(
|
||||
ctx context.Context,
|
||||
name string,
|
||||
args []string,
|
||||
env []string,
|
||||
stdin io.Reader,
|
||||
) (<-chan StreamEvent, <-chan StreamResult) {
|
||||
events := make(chan StreamEvent, 64)
|
||||
results := make(chan StreamResult, 1)
|
||||
|
||||
go func() {
|
||||
defer close(events)
|
||||
defer close(results)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
|
||||
// Entorno: base + extra
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
}
|
||||
|
||||
if stdin != nil {
|
||||
cmd.Stdin = stdin
|
||||
}
|
||||
|
||||
// Process group propio para matar hijos al recibir SIGTERM/SIGKILL
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
results <- StreamResult{ExitCode: -1, Err: fmt.Errorf("stdout pipe: %w", err), DurationMs: 0}
|
||||
return
|
||||
}
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
results <- StreamResult{ExitCode: -1, Err: fmt.Errorf("stderr pipe: %w", err), DurationMs: 0}
|
||||
return
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
results <- StreamResult{ExitCode: -1, Err: fmt.Errorf("start: %w", err), DurationMs: 0}
|
||||
return
|
||||
}
|
||||
|
||||
// Goroutine de supervision de ctx: SIGTERM → grace 2s → SIGKILL
|
||||
ctxDone := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
|
||||
timer := time.NewTimer(2 * time.Second)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
case <-ctxDone:
|
||||
}
|
||||
}
|
||||
case <-ctxDone:
|
||||
}
|
||||
}()
|
||||
|
||||
send := func(stream, line string) {
|
||||
ev := StreamEvent{Stream: stream, Line: line, Time: time.Now()}
|
||||
select {
|
||||
case events <- ev:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
// Leer stdout y stderr concurrentemente
|
||||
const bufSize = 1024 * 1024 // 1 MB para lineas largas (sd-cli progress, etc.)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
scanPipe := func(r io.Reader, stream string) {
|
||||
defer wg.Done()
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, bufSize), bufSize)
|
||||
for sc.Scan() {
|
||||
send(stream, sc.Text())
|
||||
}
|
||||
}
|
||||
|
||||
wg.Add(2)
|
||||
go scanPipe(stdoutPipe, "stdout")
|
||||
go scanPipe(stderrPipe, "stderr")
|
||||
|
||||
wg.Wait()
|
||||
close(ctxDone) // señal al supervisor de ctx para que pare
|
||||
|
||||
exitCode := 0
|
||||
var waitErr error
|
||||
if err := cmd.Wait(); err != nil {
|
||||
waitErr = err
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
waitErr = nil // exit code no-cero no es un error de spawn
|
||||
}
|
||||
}
|
||||
|
||||
// Si el contexto fue cancelado, reportar como error de cancelacion
|
||||
if ctx.Err() != nil && waitErr == nil {
|
||||
waitErr = ctx.Err()
|
||||
}
|
||||
|
||||
results <- StreamResult{
|
||||
ExitCode: exitCode,
|
||||
Err: waitErr,
|
||||
DurationMs: time.Since(start).Milliseconds(),
|
||||
}
|
||||
}()
|
||||
|
||||
return events, results
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: subprocess_stream
|
||||
kind: function
|
||||
lang: go
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func SubprocessStream(ctx context.Context, name string, args []string, env []string, stdin io.Reader) (<-chan StreamEvent, <-chan StreamResult)"
|
||||
description: "Lanza un subproceso y retorna dos canales: uno con StreamEvent (linea de stdout/stderr con timestamp) y otro con un unico StreamResult (ExitCode, Err, DurationMs). Cancelar ctx envia SIGTERM al proceso; si no termina en 2s, SIGKILL."
|
||||
tags: [subprocess, exec, stream, stdout, stderr, process, concurrency, io, primitiva]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [bufio, context, fmt, io, os, os/exec, sync, syscall, time]
|
||||
params:
|
||||
- name: ctx
|
||||
desc: "Contexto de cancelacion. Al cancelar, el proceso recibe SIGTERM; si no muere en 2s, SIGKILL. Usar context.WithTimeout para acotar duracion maxima."
|
||||
- name: name
|
||||
desc: "Nombre o path del ejecutable a lanzar (ej. 'echo', '/usr/bin/python3')."
|
||||
- name: args
|
||||
desc: "Argumentos del proceso. Puede ser nil o vacio."
|
||||
- name: env
|
||||
desc: "Variables de entorno adicionales en formato 'KEY=VALUE'. Se concatenan con os.Environ(). Puede ser nil."
|
||||
- name: stdin
|
||||
desc: "Stdin del proceso. Puede ser nil si el proceso no necesita entrada."
|
||||
output: "Dos canales: events (<-chan StreamEvent) cerrado cuando ambos pipes EOF; result (<-chan StreamResult) con exactamente un valor cuando el proceso termina. El caller DEBE consumir events hasta cierre o cancelar ctx para evitar bloquear goroutines internas."
|
||||
tested: true
|
||||
tests:
|
||||
- "echo stdout llega como evento y ExitCode 0"
|
||||
- "stderr llega como evento con stream stderr"
|
||||
- "exit code no-cero se reporta en StreamResult"
|
||||
- "ctx cancelado termina el proceso"
|
||||
- "multiples lineas stdout"
|
||||
test_file_path: "functions/core/subprocess_stream_test.go"
|
||||
file_path: "functions/core/subprocess_stream.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
events, results := core.SubprocessStream(ctx, "grep", []string{"-rn", "TODO", "."}, nil, nil)
|
||||
|
||||
for ev := range events {
|
||||
switch ev.Stream {
|
||||
case "stdout":
|
||||
fmt.Println(ev.Line)
|
||||
case "stderr":
|
||||
fmt.Fprintln(os.Stderr, "[stderr]", ev.Line)
|
||||
}
|
||||
}
|
||||
|
||||
res := <-results
|
||||
if res.ExitCode != 0 || res.Err != nil {
|
||||
log.Printf("grep exit=%d err=%v duration=%dms", res.ExitCode, res.Err, res.DurationMs)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- El canal `events` tiene buffer de 64. Si el caller deja de consumir y el buffer se llena, las goroutinas internas se bloquean hasta que haya espacio o el ctx sea cancelado.
|
||||
- El scanner de cada pipe tiene un buffer de 1 MB para tolerar lineas muy largas (progreso de CLIs tipo sd-cli, barras ANSI largas).
|
||||
- Los structs `StreamEvent` y `StreamResult` se declaran en el mismo archivo para que el paquete `core` los exporte sin imports adicionales.
|
||||
- Generaliza el patron de `claude_stream_go_core` desacoplando el lanzamiento de subprocesos del protocolo especifico de claude (NDJSON/stream-json). `claude_stream_go_core` puede reimplementarse internamente usando esta funcion como primitiva.
|
||||
- `cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}` crea un process group propio; SIGTERM/SIGKILL se envian con `Kill(-pgid, sig)` para matar tambien los procesos hijo del hijo.
|
||||
@@ -0,0 +1,132 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSubprocessStream(t *testing.T) {
|
||||
t.Run("echo stdout llega como evento y ExitCode 0", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
events, results := SubprocessStream(ctx, "echo", []string{"hola"}, nil, nil)
|
||||
|
||||
var got []StreamEvent
|
||||
for ev := range events {
|
||||
got = append(got, ev)
|
||||
}
|
||||
|
||||
res := <-results
|
||||
|
||||
if res.ExitCode != 0 {
|
||||
t.Errorf("ExitCode = %d, want 0 (err: %v)", res.ExitCode, res.Err)
|
||||
}
|
||||
if res.Err != nil {
|
||||
t.Errorf("unexpected Err: %v", res.Err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d events, want 1", len(got))
|
||||
}
|
||||
if got[0].Stream != "stdout" {
|
||||
t.Errorf("Stream = %q, want %q", got[0].Stream, "stdout")
|
||||
}
|
||||
if got[0].Line != "hola" {
|
||||
t.Errorf("Line = %q, want %q", got[0].Line, "hola")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stderr llega como evento con stream stderr", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// sh -c "echo msg >&2" escribe a stderr
|
||||
events, results := SubprocessStream(ctx, "sh", []string{"-c", "echo error_msg >&2"}, nil, nil)
|
||||
|
||||
var got []StreamEvent
|
||||
for ev := range events {
|
||||
got = append(got, ev)
|
||||
}
|
||||
res := <-results
|
||||
|
||||
if res.ExitCode != 0 {
|
||||
t.Errorf("ExitCode = %d, want 0", res.ExitCode)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d events, want 1", len(got))
|
||||
}
|
||||
if got[0].Stream != "stderr" {
|
||||
t.Errorf("Stream = %q, want %q", got[0].Stream, "stderr")
|
||||
}
|
||||
if got[0].Line != "error_msg" {
|
||||
t.Errorf("Line = %q, want %q", got[0].Line, "error_msg")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exit code no-cero se reporta en StreamResult", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
events, results := SubprocessStream(ctx, "sh", []string{"-c", "exit 42"}, nil, nil)
|
||||
|
||||
for range events {
|
||||
}
|
||||
res := <-results
|
||||
|
||||
if res.ExitCode != 42 {
|
||||
t.Errorf("ExitCode = %d, want 42", res.ExitCode)
|
||||
}
|
||||
if res.Err != nil {
|
||||
t.Errorf("unexpected Err: %v", res.Err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ctx cancelado termina el proceso", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// proceso que dura mucho; cancelamos enseguida
|
||||
ctxShort, cancelShort := context.WithTimeout(ctx, 100*time.Millisecond)
|
||||
defer cancelShort()
|
||||
|
||||
events, results := SubprocessStream(ctxShort, "sleep", []string{"60"}, nil, nil)
|
||||
|
||||
for range events {
|
||||
}
|
||||
res := <-results
|
||||
|
||||
// Tras cancelacion el proceso debe haber terminado (ExitCode != 0 o Err de ctx)
|
||||
if res.ExitCode == 0 && res.Err == nil {
|
||||
t.Error("expected non-zero exit or ctx error after cancellation")
|
||||
}
|
||||
if res.DurationMs > 3000 {
|
||||
t.Errorf("took %d ms, expected < 3000 (should have been killed)", res.DurationMs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiples lineas stdout", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
events, results := SubprocessStream(ctx, "sh", []string{"-c", "printf 'a\nb\nc\n'"}, nil, nil)
|
||||
|
||||
var lines []string
|
||||
for ev := range events {
|
||||
if ev.Stream == "stdout" {
|
||||
lines = append(lines, ev.Line)
|
||||
}
|
||||
}
|
||||
<-results
|
||||
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("got %d stdout lines, want 3: %v", len(lines), lines)
|
||||
}
|
||||
want := []string{"a", "b", "c"}
|
||||
for i, w := range want {
|
||||
if lines[i] != w {
|
||||
t.Errorf("line[%d] = %q, want %q", i, lines[i], w)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user