The Idea
Instead of having "process tasks" as the primary abstraction with mocks/fakes for testing, flip it: goroutines are the fundamental task abstraction, and process management is just one implementation.
┌─────────────────────────────────────────────────────────────────┐
│ TaskFunc │
│ func(ctx context.Context, stdin io.Reader, │
│ stdout, stderr io.Writer) int │
└─────────────────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ Pure Go function │ │ Process-bridging │
│ (for tests) │ │ function (production) │
│ │ │ │
│ Does computation │ │ Starts systemd unit │
│ in-process │ │ Bridges I/O to process │
│ │ │ Returns exit code │
└──────────────────────────┘ └──────────────────────────┘
Why
-
Testability without mocks — Tests use real goroutines doing real I/O through channels/pipes. No fake responses, no recording calls — actual concurrent behavior.
-
testing/synctest compatibility — Go 1.25's synctest package virtualizes time and provides Wait() for deterministic concurrency testing. Goroutine tasks work perfectly with this; process spawning doesn't.
-
No zombie risk in tests — Process-based tests can leave zombies if something crashes. Goroutines just get garbage collected.
-
Cleaner architecture — The core domain logic (session management, output capture, journal integration) doesn't care whether the "task" is a goroutine or a process. The process is an implementation detail.
What It Would Look Like
// The universal task signature
type TaskFunc func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) int
// For tests — pure Go
spec := TaskSpec{
Func: func(ctx context.Context, in io.Reader, out, err io.Writer) int {
fmt.Fprintln(out, "hello from goroutine")
return 0
},
}
// For production — wraps a process
spec := TaskSpec{
Func: ProcessTask(systemd, TransientSpec{
Command: []string{"bash", "-c", userCommand},
// ...
}),
}
The ProcessTask function returns a TaskFunc — it's a goroutine whose job is to:
- Start the systemd transient unit
- Bridge stdin/stdout/stderr between Go I/O and the process
- Wait for exit
- Return the exit code
Current State
The groundwork is in place:
Runtime struct with injectable Systemd, Journal, SessionClient
Journal.Follow returns iter.Seq[JournalEntry] for natural iteration
- Lifecycle events (
SWASH_EVENT=started/exited) decouple from systemd internals
- Go 1.25.5 with
testing/synctest available
Implementation Steps
- Define
TaskFunc and TaskSpec types
- Create
TaskRunner interface with Start, List, Stop methods
- Implement
GoroutineRunner — manages goroutine tasks with channels for I/O
- Implement
ProcessTask — a TaskFunc that bridges to systemd
- Refactor
Runtime to use TaskRunner instead of direct systemd calls
- Write tests using pure goroutine tasks +
synctest
Related
The Idea
Instead of having "process tasks" as the primary abstraction with mocks/fakes for testing, flip it: goroutines are the fundamental task abstraction, and process management is just one implementation.
Why
Testability without mocks — Tests use real goroutines doing real I/O through channels/pipes. No fake responses, no recording calls — actual concurrent behavior.
testing/synctestcompatibility — Go 1.25's synctest package virtualizes time and providesWait()for deterministic concurrency testing. Goroutine tasks work perfectly with this; process spawning doesn't.No zombie risk in tests — Process-based tests can leave zombies if something crashes. Goroutines just get garbage collected.
Cleaner architecture — The core domain logic (session management, output capture, journal integration) doesn't care whether the "task" is a goroutine or a process. The process is an implementation detail.
What It Would Look Like
The
ProcessTaskfunction returns aTaskFunc— it's a goroutine whose job is to:Current State
The groundwork is in place:
Runtimestruct with injectableSystemd,Journal,SessionClientJournal.Followreturnsiter.Seq[JournalEntry]for natural iterationSWASH_EVENT=started/exited) decouple from systemd internalstesting/synctestavailableImplementation Steps
TaskFuncandTaskSpectypesTaskRunnerinterface withStart,List,StopmethodsGoroutineRunner— manages goroutine tasks with channels for I/OProcessTask— aTaskFuncthat bridges to systemdRuntimeto useTaskRunnerinstead of direct systemd callssynctestRelated
97b21e5adds Runtime abstraction and lifecycle eventstesting/synctestdocs: https://pkg.go.dev/testing/synctest