2 Commits
Author SHA1 Message Date
clawbot 7b34edf12e Re-vendor the standard files from sneak/prompts dd4027b (closes #33)
check / check (push) Failing after 4s
REPO_POLICIES.md, .golangci.yml, the CI workflow and the scripts the
policy keeps identical across repositories are copies of the files at
that commit. .gitignore, .editorconfig and the new .dockerignore are the
canonical files followed by this repository's own entries. The lint
stage moves to the golangci-lint image the policy pins, in the same
commit as .golangci.yml. The format check leaves the lint stage and runs
on the host from script/check, which script/cibuild now runs after
script/bootstrap. The last Dockerfile stage runs script/bootstrap, which
installs goimports with go install at a pinned commit; script/fmt runs
that goimports.

Model: opus-5-5
2026-10-06 13:37:05 +00:00
clawbot db3373c71d Give the webhook handler a timeout (closes #38)
check / check (push) Successful in 24s
The webhook handler's client had no timeout, and slog calls the handler
inside the log call, so a server that accepted the connection and never
answered stopped that log call for good, and every later one. A request
still running after 5 seconds, reading the answer included, now fails
with a timeout error. The handler also reads the answer to the end
before closing it, so the connection is reused for the next record.

Two new tests point the handler at a server that never answers and at
one that sends its status and then stalls the answer, and check that
Handle returns a timeout error within the timeout. The README states
the timeout.

Model: opus-5-5
2026-10-06 15:10:23 +02:00
7 changed files with 178 additions and 16 deletions
+3
View File
@@ -76,3 +76,6 @@
/cmd/example/example
/*.test
/*.out
# aider's files, among them a config file that can hold an API key.
**/.aider*
+9 -6
View File
@@ -116,7 +116,9 @@ the record:
stdout, wrapped, so `errors.Is` still matches the original
- `WebhookHandler` returns an error when the request fails, and also when
the server answers with a status outside 2xx, a redirect included,
since it does not follow redirects
since it does not follow redirects. A request that has not finished
after 5 seconds fails with a timeout error, so a webhook server that
never answers holds up a log call for 5 seconds at most
- `MultiplexHandler`, which simplelog installs as the default, passes the
record to every handler it holds even after one of them fails, then
returns all their errors joined with `errors.Join` (nil if none failed)
@@ -139,9 +141,10 @@ development workflow, and the Makefile targets are thin shims that call them.
The scripts are POSIX sh (not bash) so they run in minimal containers such as
alpine. We provide:
- `script/bootstrap` — install all dependencies (go if missing, then
`go mod download`); golangci-lint is not installed, since it runs only in
Docker
- `script/bootstrap` — install all dependencies (go if missing, goimports with
`go install` at a pinned commit unless the installed one already reports the
pinned version, then `go mod download`); golangci-lint is not installed,
since it runs only in Docker
- `script/setup` — set up the repo for development after a fresh clone: runs
`script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (our own extension); used by
@@ -152,8 +155,8 @@ alpine. We provide:
- `script/lint` — run golangci-lint in Docker by building only the `lint`
stage of the `Dockerfile`, without the build cache, so every run lints; the
image is tagged `simplelog-lint`
- `script/fmt` — format all files with goimports (writes); goimports runs
with `go run` at a pinned commit, so nothing needs to install it
- `script/fmt` — format all files (writes) with the goimports that
`script/bootstrap` installs
- `script/fmt-check` — check formatting (read-only); fails if `gofmt -l`
reports files
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
+4 -1
View File
@@ -29,7 +29,10 @@ Started, Rationale, Design, TODO, License, Author
finishes the Makefile and policy-files step. `script/cibuild` now
bootstraps and runs `script/check` before building the image, the
format check runs only on the host, the last `Dockerfile` stage runs
`script/bootstrap`, and `script/fmt` runs goimports at a pinned commit
`script/bootstrap`, which installs goimports at a pinned commit
* 2026-10-06: a webhook request now times out after 5 seconds, so a
server that never answers no longer stops every log call; the webhook
handler also reads each answer to the end so its connection is reused
* 2026-10-06: the console handler takes the file and line it prints from
the record's `PC` instead of counting stack frames, so they name the
call site whether the record came through a `slog.Logger` or straight
+101
View File
@@ -5,6 +5,7 @@ import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"strings"
@@ -160,3 +161,103 @@ func TestWebhookHandlerReturnsErrorOnRedirect(t *testing.T) {
t.Fatalf("Handle returned %v, want an error for the redirect", err)
}
}
// A server that accepts the request and never answers must not hold up
// the log call: Handle gives up after webhookTimeout and says why.
func TestWebhookHandlerTimesOutOnServerThatNeverAnswers(t *testing.T) {
t.Parallel()
testEnded := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
<-testEnded
},
))
// server.Close waits for running requests, so the server's handler
// is released first.
defer func() {
close(testEnded)
server.Close()
}()
handler, err := NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
// Handle runs in a goroutine so that a lost timeout fails the test
// instead of hanging the test run.
handleErr := make(chan error, 1)
go func() {
handleErr <- handler.Handle(context.Background(), errorTestRecord())
}()
select {
case err = <-handleErr:
case <-time.After(webhookTimeout + time.Second):
t.Fatal("Handle did not return within webhookTimeout")
}
var netErr net.Error
if !errors.As(err, &netErr) || !netErr.Timeout() {
t.Fatalf("Handle returned %v, want a timeout error", err)
}
}
// A server that sends a 2xx status and then never finishes the answer
// must not hold up the log call either: reading the answer counts toward
// webhookTimeout, and Handle returns the read's error.
func TestWebhookHandlerTimesOutOnServerThatStallsTheAnswer(t *testing.T) {
t.Parallel()
testEnded := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
// Flush sends the status now; the answer stays unfinished
// until the test ends.
err := http.NewResponseController(w).Flush()
if err != nil {
t.Errorf("flush the status: %v", err)
}
<-testEnded
},
))
// server.Close waits for running requests, so the server's handler
// is released first.
defer func() {
close(testEnded)
server.Close()
}()
handler, err := NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
// Handle runs in a goroutine so that a lost timeout fails the test
// instead of hanging the test run.
handleErr := make(chan error, 1)
go func() {
handleErr <- handler.Handle(context.Background(), errorTestRecord())
}()
select {
case err = <-handleErr:
case <-time.After(webhookTimeout + time.Second):
t.Fatal("Handle did not return within webhookTimeout")
}
var netErr net.Error
if !errors.As(err, &netErr) || !netErr.Timeout() {
t.Fatalf("Handle returned %v, want a timeout error", err)
}
}
+35 -1
View File
@@ -7,6 +7,11 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# goimports from golang.org/x/tools v0.30.0, 2025-02-10: the last release
# that Go 1.22, the Go of go.mod and the Dockerfile, can build.
GOIMPORTS_COMMIT="09747cdf594a7924dcecb506312be3bd6e437962"
GOIMPORTS_VERSION="v0.30.0"
PKGMGR=""
SUDO=""
@@ -47,6 +52,35 @@ missing() {
! command -v "$1" >/dev/null 2>&1
}
# Print the golang.org/x/tools version that the goimports on PATH was
# built from, or nothing. goimports has no --version flag; `go version -m`
# prints the module versions Go records in every binary it builds.
goimports_version() {
bin="$(command -v goimports)" || return 0
go version -m "$bin" 2>/dev/null |
awk '$1 == "mod" && $2 == "golang.org/x/tools" { print $3 }'
}
# Install goimports at the pinned commit unless the one on PATH already
# reports the pinned version. go install writes to GOBIN, or to the bin
# directory of GOPATH when GOBIN is unset, which need not be on the
# caller's PATH; script/fmt puts that directory first on PATH the same
# way, so the check here sees the goimports that script/fmt runs.
ensure_goimports() {
gobin="$(go env GOBIN)"
PATH="${gobin:-$(go env GOPATH)/bin}:$PATH"
if [ "$(goimports_version)" != "$GOIMPORTS_VERSION" ]; then
go install "golang.org/x/tools/cmd/goimports@$GOIMPORTS_COMMIT"
hash -r
if [ "$(goimports_version)" != "$GOIMPORTS_VERSION" ]; then
echo "bootstrap: goimports on PATH is not $GOIMPORTS_VERSION" \
"after installing it: $(command -v goimports || echo none)" >&2
exit 1
fi
fi
echo "goimports $GOIMPORTS_VERSION at $(command -v goimports)"
}
main() {
cd "$ROOT"
@@ -55,7 +89,7 @@ main() {
if missing go; then pkg_install go golang go go; fi
# golangci-lint is not installed: it runs only in docker (script/lint).
# Nor is goimports: script/fmt runs it with `go run` at a pinned commit.
ensure_goimports
go mod download
+7 -7
View File
@@ -1,17 +1,17 @@
#!/bin/sh
# script/fmt: format all files (writes). goimports runs with `go run` at
# a pinned commit, so nothing installs it and every machine formats with
# the same version.
# script/fmt: format all files (writes) with the goimports that
# script/bootstrap installs. It puts Go's bin directory first on PATH, as
# script/bootstrap does, so it runs that goimports even when the directory
# is not on the caller's PATH.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# goimports from golang.org/x/tools v0.51.0, 2026-10-02
GOIMPORTS="golang.org/x/tools/cmd/goimports@ea2f152dc35325e4b9b639583972375972350a84"
main() {
cd "$ROOT"
go run "$GOIMPORTS" -l -w .
gobin="$(go env GOBIN)"
PATH="${gobin:-$(go env GOPATH)/bin}:$PATH"
goimports -l -w .
}
main "$@"
+19 -1
View File
@@ -6,15 +6,22 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"time"
)
// errWebhookStatus is returned when the webhook answers with a status
// outside 2xx.
var errWebhookStatus = errors.New("webhook did not accept the record")
// webhookTimeout bounds each webhook request, reading the answer
// included. Handle runs inside the log call, so a server that never
// answers would otherwise hold that call up for good.
const webhookTimeout = 5 * time.Second
// WebhookHandler POSTs each log record as JSON to a configured webhook
// URL.
type WebhookHandler struct {
@@ -34,6 +41,7 @@ func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
return &WebhookHandler{
webhookURL: webhookURL,
client: &http.Client{
Timeout: webhookTimeout,
// Following a redirect can resend the request as a GET
// without the record, so Handle gets the redirect answer
// itself and returns it as an error.
@@ -80,7 +88,8 @@ func (w *WebhookHandler) WithGroup(name string) slog.Handler {
// Handle marshals the record, with its attributes, to one JSON object and
// POSTs it to the webhook URL. It returns an error when the request fails
// or the server answers with a status outside 2xx.
// or runs past webhookTimeout, or when the server answers with a status
// outside 2xx.
func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error {
jsonData, err := json.Marshal(recordToMap(record, w.attrs))
if err != nil {
@@ -106,6 +115,15 @@ func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error {
defer func() { _ = response.Body.Close() }()
// The answer is read to the end so the client can reuse the
// connection for the next record. The read counts toward
// webhookTimeout, so a server that sends its status and then stalls
// fails here.
_, err = io.Copy(io.Discard, response.Body)
if err != nil {
return fmt.Errorf("error reading webhook answer: %w", err)
}
if response.StatusCode < http.StatusOK ||
response.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("%w: %s", errWebhookStatus, response.Status)