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:
2026-05-13 00:50:34 +02:00
parent ef60449e64
commit a802f59f55
189 changed files with 18964 additions and 330 deletions
+155
View File
@@ -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
}
+69
View File
@@ -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.
+132
View File
@@ -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)
}
}
})
}