Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
80ad9df
Build errand for Windows and run jobs in Job Objects
claude Sep 30, 2026
b647eb2
Run Windows commands the way Windows finds them
claude Sep 30, 2026
3d9a55e
Authenticate local socket peers on Windows by user SID
claude Sep 30, 2026
435951b
Prepare the Windows service runtime and detect stale sockets portably
claude Sep 30, 2026
368c935
Install the Windows runner as a Task Scheduler logon task
claude Sep 30, 2026
377093c
Keep submitted file modes on Windows runners and ship Windows zips
claude Sep 30, 2026
618b73a
Let the Windows runner open its cache store; test a job end to end
claude Sep 30, 2026
8910d14
Keep Unix-only setup and runtime tests off Windows
claude Sep 30, 2026
37cadcb
Fix Windows job admission and keep placement code where it was
claude Sep 30, 2026
db1afe2
Release open handles before deleting on Windows
claude Sep 30, 2026
c59250d
Keep tree removal where CodeQL already tracks it
claude Sep 30, 2026
ba5b0c0
Fix clean Windows setup and upgrades found on a real VM
claude Oct 1, 2026
a29416f
Keep a detached Windows runner's standard handles valid
claude Oct 1, 2026
5c63d9d
Drop the temporary Windows CI survey
claude Oct 1, 2026
4e2bfe4
Fix Windows symlink round trips and reserved device names
lydakis Oct 1, 2026
a807aed
Fix Windows workspace merges and protect registered task edits
lydakis Oct 1, 2026
fb4de8d
Normalize Task Scheduler export metadata and account identities
lydakis Oct 1, 2026
b6e4670
Open Windows apply backups and staging files with flush access
lydakis Oct 1, 2026
fc01dd3
Keep runner helper programs from flashing console windows
lydakis Oct 1, 2026
f3aa2d0
Say plainly that Windows can't send jobs yet
claude Oct 1, 2026
09d60ac
Preserve Windows directory links and resolve effective PATHEXT
lydakis Oct 1, 2026
73d6622
Keep the Unix symlink call where CodeQL already tracks it
claude Oct 1, 2026
f04676f
Test Windows directory links without the Windows client
claude Oct 1, 2026
497453f
Exercise runner link pushes without Windows client state
lydakis Oct 1, 2026
b0fa7f9
Honor explicit Windows executable names before PATHEXT suffixes
lydakis Oct 1, 2026
c410aeb
Declare PATHEXT's source in the explicit-executable Windows test
claude Oct 1, 2026
594c0e3
Release Windows Job Objects on every cleanup outcome
lydakis Oct 1, 2026
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
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,38 @@ jobs:
ruby -c dist/errand.rb
cd dist
sha256sum --check checksums.txt

windows:
name: Check (windows-2025)
runs-on: windows-2025
timeout-minutes: 20
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: ${{ inputs.ref == '' }}
cache-dependency-path: go.sum
- name: Vet
run: go vet ./...
- name: Build and check binary
run: |
go build -trimpath -o "$RUNNER_TEMP/errand.exe" ./cmd/errand
"$RUNNER_TEMP/errand.exe" version
# Packages are added here as their Windows support lands.
- name: Test Windows-supported packages
run: >-
go test -count=1 -timeout=5m
./internal/archive ./internal/durable ./internal/filelock ./internal/fsidentity ./internal/fsowner
./internal/logio ./internal/manifest ./internal/pathpolicy ./internal/placement
./internal/proctree ./internal/proto ./internal/serviceruntime ./internal/setup
./internal/unixpeer
# Windows-only tests in packages whose other tests still assume Unix.
- name: Test Windows-specific behavior
run: go test -count=1 -timeout=5m -run 'OnWindows$' ./...
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ jobs:
tar -xzf "errand_${version}_darwin_${architecture}.tar.gz" -C smoke
codesign --verify --strict smoke/errand
test "$(smoke/errand version)" = "errand ${version}"
for architecture in amd64 arm64; do
unzip -l "errand_${version}_windows_${architecture}.zip" | grep -q ' errand\.exe$'
done
- name: Verify release tag before publishing
env:
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
Expand All @@ -209,7 +212,7 @@ jobs:
if [[ "$RELEASE_TAG" == *-* ]]; then
options+=(--prerelease)
fi
gh release create "$RELEASE_TAG" dist/*.tar.gz dist/checksums.txt dist/errand.rb \
gh release create "$RELEASE_TAG" dist/*.tar.gz dist/*.zip dist/checksums.txt dist/errand.rb \
--verify-tag --draft --title "$RELEASE_TAG" --generate-notes "${options[@]}"
- name: Remove temporary signing credentials
if: always()
Expand Down
5 changes: 4 additions & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ builds:
binary: errand
env:
- CGO_ENABLED=0
goos: [darwin, linux]
goos: [darwin, linux, windows]
goarch: [amd64, arm64]
flags: [-trimpath]
ldflags:
Expand All @@ -20,6 +20,9 @@ builds:
archives:
- ids: [errand]
formats: [tar.gz]
format_overrides:
- goos: windows
formats: [zip]
name_template: "errand_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
files: [LICENSE, README.md]

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ errand -- make test

## Install

Install Errand on both machines. It supports macOS and Linux.
Install Errand on both machines. It supports macOS and Linux. A Windows PC can
be a runner too, reached over Tailscale; see [Windows runner (experimental)](docs/WINDOWS.md).

With [Homebrew](https://brew.sh):

Expand Down
10 changes: 10 additions & 0 deletions cmd/errand/console_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//go:build !windows

package main

import "os"

func detachServiceConsole(*os.File) {}

// Unix service managers stop the runner with SIGTERM and record it.
func logServiceStop() {}
56 changes: 56 additions & 0 deletions cmd/errand/console_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//go:build windows

package main

import (
"log"
"os"
"os/signal"
"syscall"
"unsafe"

"golang.org/x/sys/windows"
)

var (
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
procGetConsoleProcessList = kernel32.NewProc("GetConsoleProcessList")
procFreeConsole = kernel32.NewProc("FreeConsole")
)

// Task Scheduler gives a console program its own visible console window. When
// the runner is that console's only process, nobody is reading it, so close
// it. A runner started from a terminal shares the console and keeps it.
//
// Detaching closes the console's handles, but the process's standard handles
// still name them. Those values are soon reused by other objects, so point
// the standard handles at the log and NUL before anything can use them.
func detachServiceConsole(logFile *os.File) {
var pids [2]uint32
n, _, _ := procGetConsoleProcessList.Call(uintptr(unsafe.Pointer(&pids[0])), uintptr(len(pids)))
if n != 1 {
return
}
nul, err := os.Open(os.DevNull)
if err != nil {
return // keep the console rather than leave stale handles
}
procFreeConsole.Call()
retiredStdio = append(retiredStdio, os.Stdin)
os.Stdin = nul
_ = windows.SetStdHandle(windows.STD_INPUT_HANDLE, windows.Handle(nul.Fd()))
_ = windows.SetStdHandle(windows.STD_OUTPUT_HANDLE, windows.Handle(logFile.Fd()))
_ = windows.SetStdHandle(windows.STD_ERROR_HANDLE, windows.Handle(logFile.Fd()))
}

// logServiceStop records why the runner stops on a console event (closing
// its window, signing out, shutting down). Unhandled, Go exits with status 2
// and leaves nothing in the log.
func logServiceStop() {
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
go func() {
log.Printf("errand serve: stopping on %v", <-stop)
os.Exit(1)
}()
}
106 changes: 106 additions & 0 deletions cmd/errand/console_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//go:build windows

package main

import (
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"syscall"
"testing"

"golang.org/x/sys/windows"
)

// A runner started by Task Scheduler owns a console that nobody reads and
// detaches from it. Its old standard handles must not reach handles opened
// later, which crashed the runner a job or two after it started. The helper
// runs in its own console, detaches, then churns handles, child processes and
// network I/O under constant garbage collection.
func TestDetachedRunnerKeepsWorkingOnWindows(t *testing.T) {
if os.Getenv("ERRAND_DETACH_HELPER") == "1" {
detachedRunnerHelper(t)
return
}
logPath := filepath.Join(t.TempDir(), "errand.log")
cmd := exec.Command(os.Args[0], "-test.run=^TestDetachedRunnerKeepsWorkingOnWindows$")
cmd.Env = append(os.Environ(), "ERRAND_DETACH_HELPER=1", "GOGC=1", "ERRAND_DETACH_LOG="+logPath)
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_CONSOLE}
if err := cmd.Run(); err != nil {
logged, _ := os.ReadFile(logPath)
t.Fatalf("detached helper failed: %v\n%s", err, logged)
}
}

func detachedRunnerHelper(t *testing.T) {
f, err := os.OpenFile(os.Getenv("ERRAND_DETACH_LOG"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
if err != nil {
t.Fatal(err)
}
// Start as Task Scheduler starts the runner: the standard handles are
// the console's own. (exec gave this process NUL instead.)
for _, std := range []struct {
name string
handle uint32
file **os.File
}{
{"CONIN$", windows.STD_INPUT_HANDLE, &os.Stdin},
{"CONOUT$", windows.STD_OUTPUT_HANDLE, &os.Stdout},
{"CONOUT$", windows.STD_ERROR_HANDLE, &os.Stderr},
} {
console, err := os.OpenFile(std.name, os.O_RDWR, 0)
if err != nil {
t.Fatal(err)
}
if err := windows.SetStdHandle(std.handle, windows.Handle(console.Fd())); err != nil {
t.Fatal(err)
}
retiredStdio = append(retiredStdio, *std.file)
*std.file = console
}
fail := func(format string, args ...any) {
fmt.Fprintf(f, format+"\n", args...)
os.Exit(1)
}
useServiceLog(f)
if handle, _ := windows.GetStdHandle(windows.STD_ERROR_HANDLE); handle != windows.Handle(f.Fd()) {
fail("stderr handle %v does not name the log %v", handle, f.Fd())
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fail("%v", err)
}
defer listener.Close()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go func() { _, _ = io.Copy(conn, conn); conn.Close() }()
}
}()
for i := range 20 {
runtime.GC()
if err := exec.Command("cmd", "/d", "/c", "exit 0").Run(); err != nil {
fail("job %d: %v", i, err)
}
conn, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
fail("dial %d: %v", i, err)
}
want := fmt.Sprintf("round %d", i)
if _, err := io.WriteString(conn, want); err != nil {
fail("%v", err)
}
got := make([]byte, len(want))
if _, err := io.ReadFull(conn, got); err != nil || string(got) != want {
fail("echo %d = %q, %v", i, got, err)
}
conn.Close()
}
}
13 changes: 13 additions & 0 deletions cmd/errand/fifo_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build unix

package main

import (
"syscall"
"testing"
)

// requireFIFOs skips tests that need named pipes in the file system.
func requireFIFOs(*testing.T) {}

func mkfifo(path string) error { return syscall.Mkfifo(path, 0o600) }
13 changes: 13 additions & 0 deletions cmd/errand/fifo_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build windows

package main

import (
"errors"
"testing"
)

// requireFIFOs skips tests that need named pipes in the file system.
func requireFIFOs(t *testing.T) { t.Skip("Windows has no FIFOs in the file system") }

func mkfifo(string) error { return errors.New("Windows has no FIFOs in the file system") }
37 changes: 35 additions & 2 deletions cmd/errand/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ import (
"net/http"
"os"
"path/filepath"
"runtime/debug"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"

"github.com/lydakis/errand/internal/client"
Expand All @@ -29,6 +29,7 @@ import (
"github.com/lydakis/errand/internal/serviceruntime"
"github.com/lydakis/errand/internal/setup"
"github.com/lydakis/errand/internal/tailnet"
"github.com/lydakis/errand/internal/unixpeer"
"github.com/lydakis/errand/internal/workspace"
)

Expand Down Expand Up @@ -96,6 +97,10 @@ func runCLI(args []string) int {
fmt.Fprintln(os.Stderr, usage)
return 2
}
if unsupportedOnThisPlatform(args[0]) {
fmt.Fprintln(os.Stderr, windowsClientUnsupported)
return 2
}
switch args[0] {
case "serve":
return cmdServe(args[1:])
Expand Down Expand Up @@ -632,10 +637,18 @@ func cmdServe(args []string) int {
listen := fs.String("listen", "", `listen address ("tailnet:7443" resolves the tailnet IP; "none" disables TCP)`)
stateDir := fs.String("state-dir", "", "receipt and job state directory")
insecure := fs.Bool("insecure-no-auth", false, "DANGEROUS: skip all authorization (tests only)")
logFile := fs.String("log-file", "", "append the runner log to this file instead of stderr")
var allowUsers stringList
fs.Var(&allowUsers, "allow-user", "tailnet login allowed to use this runner (repeatable)")
setFlagUsage(fs, "errand serve [options]")
fs.Parse(args)
if *logFile != "" {
f, err := os.OpenFile(*logFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
if err != nil {
log.Fatalf("errand serve: %v", err)
}
useServiceLog(f)
}

fileCfg, err := config.LoadDaemon(*cfgPath)
if err != nil {
Expand Down Expand Up @@ -744,6 +757,26 @@ func cmdServe(args []string) int {
return 0
}

// retiredStdio keeps replaced standard files reachable. A collected *os.File
// closes its handle, and once a Windows runner detaches its console, that
// handle value can belong to an unrelated object.
var retiredStdio []*os.File

// useServiceLog sends everything the runner writes to f, the log a service
// manager gives it.
func useServiceLog(f *os.File) {
retiredStdio = append(retiredStdio, os.Stdout, os.Stderr)
os.Stdout, os.Stderr = f, f
log.SetOutput(f)
// The runtime writes fatal errors to the process's own stderr, which
// a service may not have; keep them in the log too.
if err := debug.SetCrashOutput(f, debug.CrashOptions{}); err != nil {
log.Printf("errand serve: crash output stays on stderr: %v", err)
}
logServiceStop()
detachServiceConsole(f)
}

type tailnetDiscoverFunc func(string, string) (tailnet.Provider, error)

func resolveServeTransport(
Expand Down Expand Up @@ -791,7 +824,7 @@ func listenUnixSocket(path string) (net.Listener, error) {
conn.Close()
return nil, fmt.Errorf("local socket %q already has a live listener", path)
}
if !errors.Is(dialErr, syscall.ECONNREFUSED) {
if !unixpeer.ConnectionRefused(dialErr) {
return nil, fmt.Errorf("checking existing local socket %q: %w", path, dialErr)
}
if err := os.Remove(path); err != nil {
Expand Down
19 changes: 19 additions & 0 deletions cmd/errand/platform.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package main

import "runtime"

const windowsClientUnsupported = "errand: sending jobs from Windows isn't supported yet. This PC can run jobs sent from macOS or Linux; see docs/WINDOWS.md."

// Windows is runner-only for now. Client commands would otherwise fail later
// on Unix-only checks with errors that don't say why.
func unsupportedOnThisPlatform(command string) bool {
return runtime.GOOS == "windows" && clientCommand(command)
}

func clientCommand(command string) bool {
switch command {
case "serve", "setup", "config", "access", "doctor", "df", "gc", "version", "--version", "_stdio", "-h", "--help":
return false
}
return true
}
Loading
Loading