fix: route air's own log messages to stderr instead of stdout (#887)

* fix: route air's own log messages to stderr instead of stdout

Air's internal loggers (main, build, runner, watcher) were writing to
stdout, causing air's messages to intermix with the user's application
output. This made it impossible to separate them with shell redirections
like `air 2>/dev/null`.

Change the log output destination from os.Stdout/color.Output to
os.Stderr/color.Error so air's messages go to stderr while the user's
app output remains on stdout.

Also remove dead `c.Stdout` and `c.Stderr` assignments in startCmd()
on all platforms — these had no effect after StdoutPipe()/StderrPipe()
were already called.

Fixes #744

Signed-off-by: majiayu000 <1835304752@qq.com>

* test: add regression test for logger stderr routing

Signed-off-by: majiayu000 <1835304752@qq.com>

* fix: route warning messages in config.go to stderr

Signed-off-by: majiayu000 <1835304752@qq.com>

* fix: update smoke test to check stderr for air log messages

Since air's log output now goes to stderr, the smoke test must check
nohup.err instead of nohup.out for the "running" message.

Signed-off-by: majiayu000 <1835304752@qq.com>

* fix: print splash banner to stderr

---------

Signed-off-by: majiayu000 <1835304752@qq.com>
Co-authored-by: xiantang <zhujingdi1998@gmail.com>
This commit is contained in:
lif
2026-04-03 16:20:57 +08:00
committed by GitHub
parent cc5781144c
commit 0d9e5e1344
9 changed files with 74 additions and 33 deletions
+5 -5
View File
@@ -45,8 +45,8 @@ jobs:
sleep 15
echo "" >> main.go
sleep 5
cat nohup.out
count=$(grep "running" nohup.out | wc -l)
cat nohup.err
count=$(grep "running" nohup.err | wc -l)
if [ "$count" -eq "2" ]; then
echo "value=PASS" >> "$GITHUB_OUTPUT"
else
@@ -66,9 +66,9 @@ jobs:
Start-Sleep -Seconds 15
Add-Content -Path "main.go" -Value "`n"
Start-Sleep -Seconds 5
if (Test-Path $log) { Get-Content $log }
if (Test-Path $log) {
$count = (Select-String -Path $log -Pattern "running" | Measure-Object).Count
if (Test-Path $err) { Get-Content $err }
if (Test-Path $err) {
$count = (Select-String -Path $err -Pattern "running" | Measure-Object).Count
} else {
$count = 0
}
+1 -1
View File
@@ -75,7 +75,7 @@ func GetVersionInfo() versionInfo { //revive:disable:unexported-return
func printSplash() {
versionInfo := GetVersionInfo()
fmt.Printf(`
fmt.Fprintf(os.Stderr, `
__ _ ___
/ /\ | | | |_)
/_/--\ |_| |_| \_ %s, built with Go %s
+2 -2
View File
@@ -383,7 +383,7 @@ func (c *Config) preprocess(args map[string]TomlInfo) error {
if !c.Build.IgnoreDangerousRootDir {
return fmt.Errorf("refusing to run in %s - this would watch too many files. Please run air in a project directory", dirName)
}
fmt.Fprintln(os.Stdout, "[warning] ignoring root directory protections. This could cause excessive file watching. It is recommended to run air in a project directory")
fmt.Fprintln(os.Stderr, "[warning] ignoring root directory protections. This could cause excessive file watching. It is recommended to run air in a project directory")
}
if c.TmpDir == "" {
@@ -539,5 +539,5 @@ func warnDeprecatedBin(cfg *Config) {
if cfg.Build.Bin == "" || len(cfg.Build.Entrypoint) > 0 {
return
}
fmt.Fprintln(os.Stdout, "[warning] build.bin is deprecated; set build.entrypoint instead")
fmt.Fprintln(os.Stderr, "[warning] build.bin is deprecated; set build.entrypoint instead")
}
+12 -12
View File
@@ -336,19 +336,19 @@ cmd = "go build -o ./tmp/main ."
t.Fatalf("failed to write config: %v", err)
}
oldStdout := os.Stdout
oldStderr := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("failed to create pipe: %v", err)
}
os.Stdout = w
os.Stderr = w
_, _ = InitConfig(cfgPath, nil)
if err := w.Close(); err != nil {
t.Fatalf("failed to close writer: %v", err)
}
os.Stdout = oldStdout
os.Stderr = oldStderr
out, err := io.ReadAll(r)
if err != nil {
@@ -381,19 +381,19 @@ ignore_dangerous_root_dir = true
t.Fatalf("failed to write config: %v", err)
}
oldStdout := os.Stdout
oldStderr := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("failed to create pipe: %v", err)
}
os.Stdout = w
os.Stderr = w
_, _ = InitConfig(cfgPath, nil)
if err := w.Close(); err != nil {
t.Fatalf("failed to close writer: %v", err)
}
os.Stdout = oldStdout
os.Stderr = oldStderr
out, err := io.ReadAll(r)
if err != nil {
@@ -418,19 +418,19 @@ ignore_dangerous_root_dir = false
t.Fatalf("failed to write config: %v", err)
}
oldStdout := os.Stdout
oldStderr := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("failed to create pipe: %v", err)
}
os.Stdout = w
os.Stderr = w
_, _ = InitConfig(cfgPath, nil)
if err := w.Close(); err != nil {
t.Fatalf("failed to close writer: %v", err)
}
os.Stdout = oldStdout
os.Stderr = oldStderr
out, err := io.ReadAll(r)
if err != nil {
@@ -455,19 +455,19 @@ cmd = "go build -o ./tmp/main ."
t.Fatalf("failed to write config: %v", err)
}
oldStdout := os.Stdout
oldStderr := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("failed to create pipe: %v", err)
}
os.Stdout = w
os.Stderr = w
_, _ = InitConfig(cfgPath, nil)
if err := w.Close(); err != nil {
t.Fatalf("failed to close writer: %v", err)
}
os.Stdout = oldStdout
os.Stderr = oldStderr
out, err := io.ReadAll(r)
if err != nil {
+2 -2
View File
@@ -68,9 +68,9 @@ func newLogFunc(colorname string, cfg cfgLog) logFunc {
msg = fmt.Sprintf("[%s] %s", t, msg)
}
if colorname == rawColor {
fmt.Fprintf(os.Stdout, msg, v...)
fmt.Fprintf(os.Stderr, msg, v...)
} else {
color.New(getColor(colorname)).Fprintf(color.Output, msg, v...)
color.New(getColor(colorname)).Fprintf(color.Error, msg, v...)
}
}
}
+52
View File
@@ -0,0 +1,52 @@
package runner
import (
"io"
"os"
"strings"
"testing"
)
func TestLogFuncWritesToStderr(t *testing.T) {
t.Parallel()
// Capture stderr
oldStderr := os.Stderr
rErr, wErr, err := os.Pipe()
if err != nil {
t.Fatalf("failed to create stderr pipe: %v", err)
}
os.Stderr = wErr
// Capture stdout
oldStdout := os.Stdout
rOut, wOut, err := os.Pipe()
if err != nil {
t.Fatalf("failed to create stdout pipe: %v", err)
}
os.Stdout = wOut
logFn := newLogFunc(rawColor, cfgLog{})
logFn("test message from air")
wErr.Close()
wOut.Close()
os.Stderr = oldStderr
os.Stdout = oldStdout
stderrOut, err := io.ReadAll(rErr)
if err != nil {
t.Fatalf("failed to read stderr: %v", err)
}
stdoutOut, err := io.ReadAll(rOut)
if err != nil {
t.Fatalf("failed to read stdout: %v", err)
}
if !strings.Contains(string(stderrOut), "test message from air") {
t.Errorf("expected log output on stderr, got: %q", stderrOut)
}
if strings.Contains(string(stdoutOut), "test message from air") {
t.Errorf("log output should not appear on stdout, got: %q", stdoutOut)
}
}
-3
View File
@@ -71,9 +71,6 @@ func (e *Engine) startCmd(cmd string) (*exec.Cmd, io.ReadCloser, io.ReadCloser,
return nil, nil, nil, err
}
c.Stdout = os.Stdout
c.Stderr = os.Stderr
err = c.Start()
if err != nil {
return nil, nil, nil, err
-4
View File
@@ -4,7 +4,6 @@ package runner
import (
"io"
"os"
"os/exec"
"syscall"
"time"
@@ -70,9 +69,6 @@ func (e *Engine) startCmd(cmd string) (*exec.Cmd, io.ReadCloser, io.ReadCloser,
return nil, nil, nil, err
}
c.Stdout = os.Stdout
c.Stderr = os.Stderr
err = c.Start()
if err != nil {
return nil, nil, nil, err
-4
View File
@@ -4,7 +4,6 @@ package runner
import (
"io"
"os"
"os/exec"
"strconv"
"strings"
@@ -60,9 +59,6 @@ func (e *Engine) startCmd(cmd string) (*exec.Cmd, io.ReadCloser, io.ReadCloser,
return nil, nil, nil, err
}
c.Stdout = os.Stdout
c.Stderr = os.Stderr
err = c.Start()
if err != nil {
return nil, nil, nil, err