Skip to content

Unify task model: goroutines as the fundamental abstraction #6

Description

@mbrock

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

  1. Testability without mocks — Tests use real goroutines doing real I/O through channels/pipes. No fake responses, no recording calls — actual concurrent behavior.

  2. 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.

  3. No zombie risk in tests — Process-based tests can leave zombies if something crashes. Goroutines just get garbage collected.

  4. 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:

  1. Start the systemd transient unit
  2. Bridge stdin/stdout/stderr between Go I/O and the process
  3. Wait for exit
  4. 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

  1. Define TaskFunc and TaskSpec types
  2. Create TaskRunner interface with Start, List, Stop methods
  3. Implement GoroutineRunner — manages goroutine tasks with channels for I/O
  4. Implement ProcessTask — a TaskFunc that bridges to systemd
  5. Refactor Runtime to use TaskRunner instead of direct systemd calls
  6. Write tests using pure goroutine tasks + synctest

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions