Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
${{ matrix.module }}/go.sum
- run: go test -race -covermode=atomic -coverprofile=cover.out ./...
- run: go vet ./...
- if: matrix.module == 'dbkit/sqlite' || matrix.module == 'gonsole/auth/sqlite'
- if: matrix.module == 'dbkit/sqlite' || matrix.module == 'gonsole/auth/sqlite' || matrix.module == 'gonsole'
run: GOOS=darwin GOARCH=arm64 go vet ./... && GOOS=windows GOARCH=amd64 go vet ./...

test-postgres:
Expand Down
15 changes: 15 additions & 0 deletions gonsole/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ v0.x, minor releases may contain breaking changes.

Releases of this module are tagged `gonsole/vX.Y.Z`.

## [Unreleased]

### Added

- `Env.Listening` and `Listening`, where a server listens from the `SOCKET`, `ADDR` and `PORT` settings, the first set winning, with `CLIENT_ADDR_HEADER` and `IDLE_EXIT`.
- `ServeListening`, which serves like `Serve` on a TCP address or on a new Unix socket of mode 0600, sockets on Linux only.
- On a socket, `ServeListening` puts the visitor's address from the `CLIENT_ADDR_HEADER` header in `RemoteAddr`, and answers 400 when that header holds no single address.
- `ServeListening` drops the `CLIENT_ADDR_HEADER` header from every request, TCP included.
- `ServeListening` ends the run with no error once nothing has been open for `IDLE_EXIT`, so `serve` exits 0.
- `ServeListening` takes an idle exit only when the server's `ReadTimeout` stands above zero and its `IdleTimeout` and `ReadHeaderTimeout` are not negative.
- `ServeListening` counts a connection a handler takes over, such as a WebSocket, until it closes, so the idle exit waits for it.
- On a socket, `ServeListening` holds a lock on `<socket>.lock` while the socket exists, so two starts at once never remove each other's socket.
- A start that finds that lock taken stops with `a server already listens on <path>` when a server answers there, else with `another server is starting on <path>`.
- `OverSocket`, whether a request came over a Unix socket.

## [0.7.0] - 2026-10-09

### Added
Expand Down
51 changes: 51 additions & 0 deletions gonsole/exec_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: Apache-2.0

//go:build linux

package gonsole_test

import (
"syscall"
"testing"

"github.com/gopherium/framework/gonsole"
)

func TestMainServesOnASocket(t *testing.T) {
t.Parallel()

path := socketAt(t)
variables := []string{"MYAPP_SOCKET=" + string(path), "MYAPP_CLIENT_ADDR_HEADER=X-Client-Addr"}
cmd := exampleCommand(t, variables, "serve")
lines := launch(t, cmd)
if address := listeningAddress(t, lines); address != string(path) {
t.Fatalf("listening on %q, want the socket's path %q", address, path)
}

body := path.get(t)
signalled(t, cmd, lines, syscall.SIGTERM)
err := cmd.Wait()

if err != nil {
t.Errorf("exit = %v, want 0", err)
}
if body != "quarterly\nyearly\n" {
t.Errorf("body = %q, want the report names over the socket", body)
}
if !path.gone() {
t.Errorf("%s is still there once the program exited, want it removed", path)
}
}

func TestMainRefusesASocketWithoutTheHeaderSetting(t *testing.T) {
t.Parallel()

got := runExample(t, "", []string{"MYAPP_SOCKET=" + string(socketAt(t))}, "serve")

if got.code != gonsole.ExitFailed {
t.Errorf("code = %d, want 1, stderr %q", got.code, got.stderr)
}
if want := "myapp: MYAPP_CLIENT_ADDR_HEADER is required to serve on a socket\n"; got.stderr != want {
t.Errorf("stderr = %q, want %q", got.stderr, want)
}
}
140 changes: 98 additions & 42 deletions gonsole/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ package gonsole_test
import (
"bufio"
"bytes"
"context"
"errors"
"io"
"os"
"os/exec"
"slices"
Expand Down Expand Up @@ -35,17 +35,65 @@ func TestMain(m *testing.M) {
os.Exit(m.Run())
}

// exampleEnvironment returns the environment of the example program, no inherited MYAPP_ variable and variables added.
// settingEntry reports whether the environment entry sets a MYAPP_ variable or PORT.
func settingEntry(entry string) bool {
return strings.HasPrefix(entry, "MYAPP_") || strings.HasPrefix(entry, "PORT=")
}

// exampleEnvironment returns the environment of the example program, no inherited setting and variables added.
func exampleEnvironment(variables ...string) []string {
inherited := slices.DeleteFunc(os.Environ(), func(entry string) bool { return strings.HasPrefix(entry, "MYAPP_") })
inherited := slices.DeleteFunc(os.Environ(), settingEntry)
return append(append(inherited, exampleSwitch+"=1"), variables...)
}

// exampleBound is how long a test lets the example program run before it kills it.
const exampleBound = 10 * time.Second

// exampleCommand returns the example program over args and extra variables, killed once exampleBound passes.
func exampleCommand(t *testing.T, variables []string, args ...string) *exec.Cmd {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), exampleBound)
t.Cleanup(cancel)
cmd := exec.CommandContext(ctx, os.Args[0], args...)
cmd.Env = exampleEnvironment(variables...)
return cmd
}

// launch starts cmd and returns the lines of its stderr.
func launch(t *testing.T, cmd *exec.Cmd) *bufio.Scanner {
t.Helper()
stderr, err := cmd.StderrPipe()
if err != nil {
t.Fatalf("piping stderr: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("starting the example program: %v", err)
}
return bufio.NewScanner(stderr)
}

// untilExit returns every line left in lines.
func untilExit(lines *bufio.Scanner) []string {
var left []string
for lines.Scan() {
left = append(left, lines.Text())
}
return left
}

// signalled sends signal to the started cmd and returns the lines it writes to stderr until it exits.
func signalled(t *testing.T, cmd *exec.Cmd, lines *bufio.Scanner, signal os.Signal) []string {
t.Helper()
if err := cmd.Process.Signal(signal); err != nil {
t.Fatalf("signalling: %v", err)
}
return untilExit(lines)
}

// runExample runs the example program in its own process over args, stdin and extra variables and answers its output.
func runExample(t *testing.T, stdin string, variables []string, args ...string) result {
t.Helper()
cmd := exec.CommandContext(t.Context(), os.Args[0], args...)
cmd.Env = exampleEnvironment(variables...)
cmd := exampleCommand(t, variables, args...)
cmd.Stdin = strings.NewReader(stdin)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
Expand Down Expand Up @@ -91,8 +139,14 @@ func TestExamplePluginsDescribeTheDemoGroup(t *testing.T) {

loaded, err := p.Plugins(t.Context(), gonsole.Call{Describe: true, Env: gonsole.Env{Prefix: "MYAPP_"}})

if err != nil || loaded.Failed != nil || !slices.Equal(namespaces(loaded.Groups), []string{"demo"}) {
t.Fatalf("Plugins() = %v failing %v, %v, want demo, nil and nil", namespaces(loaded.Groups), loaded.Failed, err)
if err != nil {
t.Fatalf("Plugins() = %v, want nil", err)
}
if loaded.Failed != nil {
t.Fatalf("Plugins() failing %v, want nil", loaded.Failed)
}
if !slices.Equal(namespaces(loaded.Groups), []string{"demo"}) {
t.Fatalf("Plugins() = %v, want demo", namespaces(loaded.Groups))
}
if err := p.Check(loaded); err != nil {
t.Errorf("Check() = %v, want nil", err)
Expand Down Expand Up @@ -187,27 +241,13 @@ func TestMainServesUntilASignalEndsTheRun(t *testing.T) {
t.Run(signal.String(), func(t *testing.T) {
t.Parallel()

cmd := exec.CommandContext(t.Context(), os.Args[0], "serve")
cmd.Env = exampleEnvironment("MYAPP_ADDR=127.0.0.1:0")
stderr, err := cmd.StderrPipe()
if err != nil {
t.Fatalf("piping stderr: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("starting the example program: %v", err)
}
lines := bufio.NewScanner(stderr)
cmd := exampleCommand(t, []string{"MYAPP_ADDR=127.0.0.1:0"}, "serve")
lines := launch(t, cmd)
address := listeningAddress(t, lines)

body := get(t, address)
if err := cmd.Process.Signal(signal); err != nil {
t.Fatalf("signalling: %v", err)
}
var rest []string
for lines.Scan() {
rest = append(rest, lines.Text())
}
err = cmd.Wait()
rest := signalled(t, cmd, lines, signal)
err := cmd.Wait()

if err != nil {
t.Errorf("exit = %v, want 0", err)
Expand All @@ -222,34 +262,51 @@ func TestMainServesUntilASignalEndsTheRun(t *testing.T) {
}
}

func TestMainLetsASecondSignalEndACommandThatIgnoresTheFirst(t *testing.T) {
func TestMainEndsAnIdleServeWithExitZero(t *testing.T) {
t.Parallel()

cmd := exec.CommandContext(t.Context(), os.Args[0], "report:import", "-yes")
cmd.Env = exampleEnvironment()
got := runExample(t, "", []string{"MYAPP_ADDR=127.0.0.1:0", "MYAPP_IDLE_EXIT=200ms"}, "serve")

if got.code != gonsole.ExitDone {
t.Errorf("code = %d, want 0 within %v, stderr %q", got.code, exampleBound, got.stderr)
}
idle := strings.Index(got.stderr, `msg="no connection for the idle exit" idle_exit=200ms`)
if down := strings.Index(got.stderr, `msg="shutting down"`); idle < 0 || down < idle {
t.Errorf("stderr = %q, want the idle line before the shutting down line", got.stderr)
}
}

// importHeldOpen starts report:import -yes with its input held open and returns it and its exit once it reads.
func importHeldOpen(t *testing.T) (*exec.Cmd, <-chan error) {
t.Helper()
cmd := exampleCommand(t, nil, "report:import", "-yes")
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatalf("piping stdin: %v", err)
}
defer func() { _ = stdin.Close() }()
stderr, err := cmd.StderrPipe()
if err != nil {
t.Fatalf("piping stderr: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("starting the example program: %v", err)
}
stuck := time.AfterFunc(10*time.Second, func() { _ = cmd.Process.Kill() })
defer stuck.Stop()
lines := bufio.NewScanner(stderr)
t.Cleanup(func() { _ = stdin.Close() })
lines := launch(t, cmd)
if !lines.Scan() || lines.Text() != importing {
t.Fatalf("stderr opens with %q, want %q before any signal", lines.Text(), importing)
}
exited := make(chan error, 1)
go func() {
_, _ = io.Copy(io.Discard, stderr)
untilExit(lines)
exited <- cmd.Wait()
}()
return cmd, exited
}

// killedBySignal reports whether a signal ended the process state describes.
func killedBySignal(state *os.ProcessState) bool {
status, known := state.Sys().(syscall.WaitStatus)
return known && status.Signaled()
}

func TestMainLetsASecondSignalEndACommandThatIgnoresTheFirst(t *testing.T) {
t.Parallel()

cmd, exited := importHeldOpen(t)

_ = cmd.Process.Signal(os.Interrupt)
select {
Expand All @@ -261,8 +318,7 @@ func TestMainLetsASecondSignalEndACommandThatIgnoresTheFirst(t *testing.T) {

select {
case <-exited:
status, known := cmd.ProcessState.Sys().(syscall.WaitStatus)
if !known || !status.Signaled() {
if !killedBySignal(cmd.ProcessState) {
t.Errorf("state = %v, want the second signal to end the program", cmd.ProcessState)
}
case <-time.After(5 * time.Second):
Expand Down
Loading
Loading