Skip to content

Unify task model: goroutines as the fundamental abstraction #6

@mbrock

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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions