Compare commits

...

2 Commits

Author SHA1 Message Date
mixeme 2ab5f07c7c docs: remove implemented per-job timeout plan
The per-job command timeout is now shipped (0.12.0); its ROADMAP entry is
gone, so the implementation plan is no longer needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:38:59 +03:00
mixeme 48faddb3bd feat: per-job command timeout with global default
Add an optional per-job run timeout following the overlap_policy inherit
pattern: Job.TimeoutSeconds (0 = inherit) resolves against a new
Config.DefaultTimeoutSeconds (default 30s), replacing the hard-coded 30s
guard in runner.RunJob.

- domain/storage: new fields, default 30, load-time normalization
- runner: RunJob takes an explicit timeout; StartOnly stays untimed so it
  keeps measuring launch latency only
- app: effectiveTimeout resolves under mu into runEnv, threaded to runJob;
  seam signature and validation updated; DisplayTimeout helper
- ui: Timeout entry in the job dialog, Default timeout in Settings, and a
  Timeout row in the details panel
- tests + docs (ARCHITECTURE, STANDARDS, ROADMAP, CHANGELOG) updated;
  version bumped to 0.12.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:24:50 +03:00
24 changed files with 270 additions and 241 deletions
+16 -2
View File
@@ -87,8 +87,9 @@ flowchart LR
5. Command execution: 5. Command execution:
`runner.RunJob` builds the platform-specific invocation, executes the `runner.RunJob` builds the platform-specific invocation, executes the
command through the platform shell, captures stdout and stderr, writes one command through the platform shell under the caller-supplied timeout, captures
timestamped `.log` file, and returns a `domain.RunRecord` containing stdout and stderr, writes one timestamped `.log` file, and returns a
`domain.RunRecord` containing
`DurationMS` (wall-clock milliseconds from start to finish; for `StartOnly` `DurationMS` (wall-clock milliseconds from start to finish; for `StartOnly`
fire-and-forget jobs it measures launch latency — the time to spawn the fire-and-forget jobs it measures launch latency — the time to spawn the
process — since there is no exit to wait for). process — since there is no exit to wait for).
@@ -128,6 +129,19 @@ in flight increments `JobRuntime.PendingRuns`. When the current run finishes,
`executeRun` drains the counter by starting one deferred run per completion until `executeRun` drains the counter by starting one deferred run per completion until
`PendingRuns` reaches zero. `PendingRuns` reaches zero.
### Per-job command timeout
`domain.Job` carries a `TimeoutSeconds` field (`json:"timeout_seconds,omitempty"`),
following the same inherit pattern as the overlap policy. `0` means inherit the
global `Config.DefaultTimeoutSeconds` (default **30**); a positive value overrides
it for that job alone. `app.Service.effectiveTimeout` resolves the effective
duration under `mu` and `startRunLocked` snapshots it into `runEnv.timeout`.
`runner.RunJob(ctx, job, trigger, logsDir, timeout)` takes the resolved duration
as an argument, so the runner stays ignorant of the global config: it applies the
timeout via `context.WithTimeout` and reports `Timed out after <timeout>` on
expiry. `StartOnly` jobs run on the untimed context and so measure launch latency
only, unaffected by the run timeout.
### Run-time statistics ### Run-time statistics
`domain.JobRuntime` holds a rolling aggregate updated after each run: `domain.JobRuntime` holds a rolling aggregate updated after each run:
+11
View File
@@ -2,6 +2,17 @@
All notable GoSentry changes are recorded in this file. All notable GoSentry changes are recorded in this file.
## 0.12.0 - 2026-07-25
**Per-job command timeout:**
- Each job may now set its own run timeout (seconds) in the job dialog; leaving
it empty inherits a new **Default timeout** in Settings (default 30s), the same
inherit pattern as the overlap policy. The details panel shows the effective
value, marking inherited jobs as `(global default)`.
- The formerly hard-coded 30s guard in `runner.RunJob` is now the configurable
default. `StartOnly` fire-and-forget jobs remain unaffected by the run timeout,
continuing to measure launch latency only.
## 0.11.5 - 2026-07-01 ## 0.11.5 - 2026-07-01
**Quality and documentation polish:** **Quality and documentation polish:**
-12
View File
@@ -5,18 +5,6 @@ Completed work is recorded in [CHANGELOG.md](CHANGELOG.md), not here.
## Open Items ## Open Items
### Per-job command timeout
`runner.RunJob` applies a fixed **30s** timeout to every command (`commandTimeout`
in `src/runner/runner.go`). Long-running or interactive scripts need a longer
limit; quick health checks may need a shorter one.
Add an optional per-job timeout (seconds) on `domain.Job`, with a global default
in `gosentry.json` for jobs that leave the field empty — the same inherit pattern
as `overlap_policy`. Wire the value through `RunJob`; expose it in the job dialog
and Settings; validate on save. `StartOnly` jobs should keep measuring launch
latency only and remain unaffected by the run timeout.
### Window size persistence *(frozen)* ### Window size persistence *(frozen)*
Window size is currently **not** saved on quit or close. Saving was disabled Window size is currently **not** saved on quit or close. Saving was disabled
+4 -3
View File
@@ -17,12 +17,13 @@ in [ARCHITECTURE.md](ARCHITECTURE.md); test conventions in [TESTS.md](TESTS.md).
- `RunNow` is allowed during global pause and for disabled jobs. - `RunNow` is allowed during global pause and for disabled jobs.
- Sequential mode runs jobs FIFO by order in `jobs.json`. - Sequential mode runs jobs FIFO by order in `jobs.json`.
- Scheduler tick is 1s — sub-second `@every` intervals are not supported. - Scheduler tick is 1s — sub-second `@every` intervals are not supported.
- Command timeout is 30s globally. - Command timeout defaults to 30s globally and is overridable per job
(`Job.TimeoutSeconds`, 0 = inherit `Config.DefaultTimeoutSeconds`).
- **History tab is session-only.** `JobRuntime.Logs` exists only in memory for the - **History tab is session-only.** `JobRuntime.Logs` exists only in memory for the
current process. Log files on disk feed aggregate statistics via `SeedStats` current process. Log files on disk feed aggregate statistics via `SeedStats`
only. See [ARCHITECTURE.md](ARCHITECTURE.md). only. See [ARCHITECTURE.md](ARCHITECTURE.md).
## Out of scope ## Out of scope
Larger or blocked work is tracked in [ROADMAP.md](ROADMAP.md) (per-job timeout, Larger or blocked work is tracked in [ROADMAP.md](ROADMAP.md) (window size
window size persistence, History column filters, CI coverage gate). persistence, History column filters, CI coverage gate).
-154
View File
@@ -1,154 +0,0 @@
# План реализации: Per-job command timeout
Реализация пункта **«Per-job command timeout»** из [ROADMAP.md](../ROADMAP.md),
построенная по образцу уже реализованного `overlap_policy` — тот же паттерн
наследования «пусто → глобальный дефолт».
## Рекомендуемая модель
**Claude Opus** (Opus 4.8 или новее).
Обоснование: это не локальная правка, а сквозное изменение через слои
(`domain → storage → runner → app seam → ui → tests`) со сменой сигнатуры
`RunJob` и seam-типа `Service.runJob`, что затрагивает ~9 существующих тестов и
несколько UI-файлов. Нужна аккуратность в резолве эффективного значения под
`mu` и в сохранении контракта «раннер не знает о глобальном конфиге». Для такой
кросс-слойной работы с тестами уместен Opus; Sonnet справится с отдельными
шагами, но выше риск упустить обновление одного из вызовов/тестов.
Замечание по окружению: сборка и тесты GUI требуют **CGO + MSYS2 UCRT64**
(дефолтный Bash-env идёт с `cgo off`).
---
## Модель данных
**[src/domain/job.go](../../src/domain/job.go)** — добавить поле в `Job`:
```go
TimeoutSeconds int `json:"timeout_seconds,omitempty"` // 0 = наследовать глобальный дефолт
```
**[src/domain/config.go](../../src/domain/config.go)** — добавить в `Config`:
```go
DefaultTimeoutSeconds int `json:"default_timeout_seconds,omitempty"`
```
Ключевое соглашение (как у `OverlapPolicy`): `0` на `Job` означает
«наследовать», поэтому `normalizeJob` не должен затирать 0 глобальным значением.
## Дефолты и загрузка
**[src/storage/store.go](../../src/storage/store.go)** — по образцу `OverlapPolicy`:
- в литерал дефолтного `Config` добавить `DefaultTimeoutSeconds: 30`
(сохраняет текущее поведение — 30 с);
- в блоке нормализации после загрузки:
`if config.DefaultTimeoutSeconds <= 0 { config.DefaultTimeoutSeconds = 30 }`.
Это переносит нынешнюю константу `commandTimeout = 30s` из `runner.go` в конфиг
как значение по умолчанию.
## Runner (сохранить чистоту контракта)
Раннер не должен знать о глобальном конфиге — эффективный таймаут резолвится в
app-слое и передаётся внутрь.
**[src/runner/runner.go](../../src/runner/runner.go)**:
- сигнатура `RunJob(ctx, job, trigger, logsDir)`
`RunJob(ctx, job, trigger, logsDir, timeout time.Duration)`;
- `runCtx, cancel := context.WithTimeout(ctx, timeout)` вместо константы;
- `runStateDetail(...)` принимает `timeout` и печатает его в сообщении
`"Timed out after %s"` вместо `commandTimeout`;
- **StartOnly не трогаем**: эта ветка использует `jobInvocation(ctx, …)`
(не `runCtx`), поэтому run-таймаут её не касается — измерение launch latency
сохраняется. Константу `commandTimeout` можно удалить, `commandWaitDelay`
оставить.
## App-слой: резолв и проброс
**[src/app/run.go](../../src/app/run.go)** — добавить рядом с
`effectiveOverlapPolicy`:
```go
func (s *Service) effectiveTimeout(job *domain.Job) time.Duration {
secs := job.TimeoutSeconds
if secs <= 0 {
secs = s.store.Config.DefaultTimeoutSeconds
}
return time.Duration(secs) * time.Second
}
```
Резолвить под `mu` в `startRunLocked` и класть в снапшот `runEnv` (там же, где
`logsDir`/`maxFiles`) — новое поле `timeout time.Duration`. В `executeRun`
передавать `env.timeout` в `s.runJob(...)`.
**[src/app/service.go](../../src/app/service.go)** — обновить тип seam-поля
`runJob` (добавить `timeout time.Duration`); присваивание
`runJob: runner.RunJob` останется валидным после смены сигнатуры.
## Валидация
**[src/app/operations.go](../../src/app/operations.go)**:
- `validateJob`:
`if job.TimeoutSeconds < 0 { return errors.New("timeout must be zero (inherit) or a positive number of seconds") }`;
- `validateConfig`:
`if config.DefaultTimeoutSeconds <= 0 { return errors.New("default timeout must be a positive number of seconds") }`.
## UI
**Диалог задачи [src/ui/job_dialog.go](../../src/ui/job_dialog.go)** — рядом с
«Overlap policy»: числовой `widget.NewEntry` «Timeout (s)» с плейсхолдером-
подсказкой про наследование; пусто → `TimeoutSeconds = 0`, иначе `strconv.Atoi`
с показом ошибки как у schedule.
**Настройки [src/ui/settings_view.go](../../src/ui/settings_view.go)** — в секцию
«Queue» добавить поле «Default timeout (s)» рядом с «Default overlap policy»:
инициализация из `store.Config.DefaultTimeoutSeconds`,
`OnChanged → updateSaveState`, запись в `config.DefaultTimeoutSeconds` при
сохранении и учёт в dirty-check.
**Панель деталей [src/ui/jobs_view_details.go](../../src/ui/jobs_view_details.go)**
— новая строка «Timeout», отображающая эффективное значение через новый хелпер
в [src/app/format.go](../../src/app/format.go):
```go
func DisplayTimeout(job domain.Job, globalDefault int) string // "45 s" или "30 s (global default)"
```
по образцу `DisplayOverlapPolicy`. Прокинуть `globalDefault` в
`newDetailsPanel/update` так же, как уже прокинут `globalOverlapPolicy`.
## Тесты
- **[src/runner/runner_test.go](../../src/runner/runner_test.go)** — все 9 вызовов
`RunJob` получают новый аргумент; добавить кейс: короткий per-job таймаут →
`Failed / Timed out after …`; StartOnly с малым таймаутом → не таймаутит.
- **[src/app/run_test.go](../../src/app/run_test.go)** — тест `effectiveTimeout`:
инхерит при `TimeoutSeconds==0`, собственное значение перекрывает глобальное.
- **[src/app/operations_test.go](../../src/app/operations_test.go)** —
отрицательный per-job timeout и неположительный default отклоняются.
- **[src/app/format_test.go](../../src/app/format_test.go)** — `DisplayTimeout`
(собственное значение vs «(global default)»).
- **settings / mainwindow тесты** — при необходимости обновить конструкторы
`Config`.
## Документация
- Удалить раздел из [ROADMAP.md](../ROADMAP.md).
- Обновить упоминания сигнатуры/таймаута `RunJob` в
[ARCHITECTURE.md](../ARCHITECTURE.md).
- Запись в [CHANGELOG.md](../CHANGELOG.md).
## Порядок работ
1. `domain` → storage-дефолты (компилируется, поведение прежнее);
2. `runner` + seam-сигнатура + прогон таймаута через `runEnv` → чиним
компиляцию тестов;
3. валидация;
4. UI (диалог, настройки, детали);
5. тесты + доки.
+11
View File
@@ -102,6 +102,17 @@ func DisplayOverlapPolicy(job domain.Job, globalPolicy domain.OverlapPolicy) str
return string(globalPolicy) + " (global default)" return string(globalPolicy) + " (global default)"
} }
// DisplayTimeout formats a job's effective run timeout for the details panel.
// When the job sets its own TimeoutSeconds it is shown as-is; when 0 (inherit),
// the global default is shown with "(global default)" appended, mirroring
// DisplayOverlapPolicy.
func DisplayTimeout(job domain.Job, globalDefault int) string {
if job.TimeoutSeconds > 0 {
return fmt.Sprintf("%d s", job.TimeoutSeconds)
}
return fmt.Sprintf("%d s (global default)", globalDefault)
}
// DisplayIndex returns the position of jobIndex in the given slice of indexes, // DisplayIndex returns the position of jobIndex in the given slice of indexes,
// or 0 if not found. // or 0 if not found.
func DisplayIndex(indexes []int, jobIndex int) int { func DisplayIndex(indexes []int, jobIndex int) int {
+11
View File
@@ -163,3 +163,14 @@ func TestDisplayOverlapPolicy(t *testing.T) {
t.Errorf("inherited policy = %q, want %q", got, want) t.Errorf("inherited policy = %q, want %q", got, want)
} }
} }
func TestDisplayTimeout(t *testing.T) {
own := domain.Job{TimeoutSeconds: 45}
if got, want := DisplayTimeout(own, 30), "45 s"; got != want {
t.Errorf("per-job timeout = %q, want %q", got, want)
}
inherit := domain.Job{TimeoutSeconds: 0}
if got, want := DisplayTimeout(inherit, 30), "30 s (global default)"; got != want {
t.Errorf("inherited timeout = %q, want %q", got, want)
}
}
+6
View File
@@ -367,6 +367,9 @@ func validateJob(job domain.Job) error {
if policy != "" && policy != string(domain.OverlapPolicySkip) && policy != string(domain.OverlapPolicyQueue) { if policy != "" && policy != string(domain.OverlapPolicySkip) && policy != string(domain.OverlapPolicyQueue) {
return errors.New("overlap policy must be 'skip', 'queue', or empty") return errors.New("overlap policy must be 'skip', 'queue', or empty")
} }
if job.TimeoutSeconds < 0 {
return errors.New("timeout must be zero (inherit) or a positive number of seconds")
}
return nil return nil
} }
@@ -390,5 +393,8 @@ func validateConfig(config domain.Config) error {
if config.OverlapPolicy != domain.OverlapPolicySkip && config.OverlapPolicy != domain.OverlapPolicyQueue { if config.OverlapPolicy != domain.OverlapPolicySkip && config.OverlapPolicy != domain.OverlapPolicyQueue {
return errors.New("overlap policy must be 'skip' or 'queue'") return errors.New("overlap policy must be 'skip' or 'queue'")
} }
if config.DefaultTimeoutSeconds <= 0 {
return errors.New("default timeout must be a positive number of seconds")
}
return nil return nil
} }
+14 -10
View File
@@ -27,7 +27,7 @@ func newTempService(t *testing.T, jobs []domain.Job) *Service {
JobsPath: filepath.Join(dir, "jobs.json"), JobsPath: filepath.Join(dir, "jobs.json"),
LogsDir: filepath.Join(dir, "logs"), LogsDir: filepath.Join(dir, "logs"),
}, },
Config: domain.Config{JobsDir: ".", LogsDir: "logs", MaxLogFiles: 100, MaxLogAgeDays: 30, ExecutionMode: domain.ExecutionModeParallel, OverlapPolicy: domain.OverlapPolicySkip}, Config: domain.Config{JobsDir: ".", LogsDir: "logs", MaxLogFiles: 100, MaxLogAgeDays: 30, ExecutionMode: domain.ExecutionModeParallel, OverlapPolicy: domain.OverlapPolicySkip, DefaultTimeoutSeconds: 30},
} }
return NewService(store, jobs) return NewService(store, jobs)
} }
@@ -103,6 +103,9 @@ func TestCreateJobValidates(t *testing.T) {
if _, err := svc.CreateJob(domain.Job{Name: "A", Schedule: "@every 1m", Command: "echo", OverlapPolicy: "invalid"}); err == nil { if _, err := svc.CreateJob(domain.Job{Name: "A", Schedule: "@every 1m", Command: "echo", OverlapPolicy: "invalid"}); err == nil {
t.Error("expected error for invalid overlap policy") t.Error("expected error for invalid overlap policy")
} }
if _, err := svc.CreateJob(domain.Job{Name: "A", Schedule: "@every 1m", Command: "echo", TimeoutSeconds: -1}); err == nil {
t.Error("expected error for negative per-job timeout")
}
} }
func TestUpdateJobKeepsRuntimeAndReflectsDisable(t *testing.T) { func TestUpdateJobKeepsRuntimeAndReflectsDisable(t *testing.T) {
@@ -250,7 +253,7 @@ func TestRunNowUsesRunnerAndRecords(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan domain.RunRecord, 1) done := make(chan domain.RunRecord, 1)
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) (domain.RunRecord, error) { svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string, _ time.Duration) (domain.RunRecord, error) {
if trigger != "Manual" { if trigger != "Manual" {
t.Errorf("trigger = %q, want Manual", trigger) t.Errorf("trigger = %q, want Manual", trigger)
} }
@@ -298,7 +301,7 @@ func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
entered := make(chan struct{}, 1) entered := make(chan struct{}, 1)
release := make(chan struct{}) release := make(chan struct{})
var calls int32 var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1) atomic.AddInt32(&calls, 1)
entered <- struct{}{} entered <- struct{}{}
<-release <-release
@@ -341,7 +344,7 @@ func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
func TestRunNowAllowedWhilePaused(t *testing.T) { func TestRunNowAllowedWhilePaused(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan struct{}, 1) done := make(chan struct{}, 1)
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) { svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
select { select {
case done <- struct{}{}: case done <- struct{}{}:
default: default:
@@ -366,7 +369,7 @@ func TestRunDueStartsDueJob(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan domain.RunRecord, 1) done := make(chan domain.RunRecord, 1)
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) (domain.RunRecord, error) { svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string, _ time.Duration) (domain.RunRecord, error) {
if trigger != "Schedule" { if trigger != "Schedule" {
t.Errorf("trigger = %q, want Schedule", trigger) t.Errorf("trigger = %q, want Schedule", trigger)
} }
@@ -397,7 +400,7 @@ func TestRunDueStartsDueJob(t *testing.T) {
func TestRunDueSkipsJobNotYetDue(t *testing.T) { func TestRunDueSkipsJobNotYetDue(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
var ran int32 var ran int32
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) { svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&ran, 1) atomic.AddInt32(&ran, 1)
return domain.RunRecord{}, nil return domain.RunRecord{}, nil
} }
@@ -418,7 +421,7 @@ func TestRunDueSkipsJobInRunningState(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
var calls int32 var calls int32
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) { svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1) atomic.AddInt32(&calls, 1)
return domain.RunRecord{State: "Success"}, nil return domain.RunRecord{State: "Success"}, nil
} }
@@ -442,7 +445,7 @@ func TestRunDueSkipsJobInRunningState(t *testing.T) {
func TestRunDueDoesNothingWhilePaused(t *testing.T) { func TestRunDueDoesNothingWhilePaused(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
var ran int32 var ran int32
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) { svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&ran, 1) atomic.AddInt32(&ran, 1)
return domain.RunRecord{}, nil return domain.RunRecord{}, nil
} }
@@ -472,7 +475,7 @@ func TestStartDrivesRunDueOnTick(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan struct{}, 1) done := make(chan struct{}, 1)
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) { svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
select { select {
case done <- struct{}{}: case done <- struct{}{}:
default: default:
@@ -524,6 +527,7 @@ func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }}, {"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
{"non-positive max files", func(c *domain.Config) { c.MaxLogFiles = 0 }}, {"non-positive max files", func(c *domain.Config) { c.MaxLogFiles = 0 }},
{"non-positive max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }}, {"non-positive max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
{"non-positive default timeout", func(c *domain.Config) { c.DefaultTimeoutSeconds = 0 }},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -596,7 +600,7 @@ func TestServiceRebuiltFromPausedStoreStartsPaused(t *testing.T) {
var ran int32 var ran int32
runStarted := make(chan struct{}, 1) runStarted := make(chan struct{}, 1)
svc2.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) { svc2.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&ran, 1) atomic.AddInt32(&ran, 1)
select { select {
case runStarted <- struct{}{}: case runStarted <- struct{}{}:
+16 -1
View File
@@ -112,6 +112,7 @@ type runEnv struct {
logsDir string logsDir string
maxFiles int maxFiles int
maxAge int maxAge int
timeout time.Duration
} }
// startRunLocked transitions a job to "Running", advances its NextDue to the next // startRunLocked transitions a job to "Running", advances its NextDue to the next
@@ -142,6 +143,7 @@ func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, tr
logsDir: s.store.Paths.LogsDir, logsDir: s.store.Paths.LogsDir,
maxFiles: s.store.Config.MaxLogFiles, maxFiles: s.store.Config.MaxLogFiles,
maxAge: s.store.Config.MaxLogAgeDays, maxAge: s.store.Config.MaxLogAgeDays,
timeout: s.effectiveTimeout(job),
} }
// Capture ctx under the lock so a concurrent Start/Stop cannot swap it out // Capture ctx under the lock so a concurrent Start/Stop cannot swap it out
// from under the goroutine after we release mu. // from under the goroutine after we release mu.
@@ -155,7 +157,7 @@ func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, tr
// is not paused, deferred runs are started one at a time until PendingRuns reaches // is not paused, deferred runs are started one at a time until PendingRuns reaches
// zero. Each deferred run runs on its own goroutine. // zero. Each deferred run runs on its own goroutine.
func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger string, env runEnv) { func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger string, env runEnv) {
record, logErr := s.runJob(ctx, &jobCopy, trigger, env.logsDir) record, logErr := s.runJob(ctx, &jobCopy, trigger, env.logsDir, env.timeout)
s.mu.Lock() s.mu.Lock()
var cleanupErr, saveErr error var cleanupErr, saveErr error
@@ -208,6 +210,19 @@ func (s *Service) effectiveOverlapPolicy(job *domain.Job) domain.OverlapPolicy {
return s.store.Config.OverlapPolicy return s.store.Config.OverlapPolicy
} }
// effectiveTimeout resolves the run timeout that actually governs a job: the
// job's own TimeoutSeconds when positive, otherwise the global
// Config.DefaultTimeoutSeconds. A non-positive Job.TimeoutSeconds means "inherit
// the global default", which is why normalizeJob leaves 0 rather than
// backfilling the configured value. The caller must hold mu.
func (s *Service) effectiveTimeout(job *domain.Job) time.Duration {
secs := job.TimeoutSeconds
if secs <= 0 {
secs = s.store.Config.DefaultTimeoutSeconds
}
return time.Duration(secs) * time.Second
}
// anyRunningLocked reports whether any loaded job is currently in the "Running" // anyRunningLocked reports whether any loaded job is currently in the "Running"
// state. It backs the sequential-mode guards in RunNow and RunDue. The caller // state. It backs the sequential-mode guards in RunNow and RunDue. The caller
// must hold mu. // must hold mu.
+29 -11
View File
@@ -135,7 +135,7 @@ func TestRunDueParallelStartsAllDueJobs(t *testing.T) {
entered := make(chan int, 2) entered := make(chan int, 2)
release := make(chan struct{}) release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
entered <- job.ID entered <- job.ID
<-release <-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
@@ -176,7 +176,7 @@ func TestRunDueSequentialSerializes(t *testing.T) {
entered := make(chan int, 2) entered := make(chan int, 2)
release := make(chan struct{}) release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
entered <- job.ID entered <- job.ID
<-release <-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
@@ -213,7 +213,7 @@ func TestRunDueSkipDropsOverlap(t *testing.T) {
entered := make(chan int, 2) entered := make(chan int, 2)
release := make(chan struct{}) release := make(chan struct{})
var calls int32 var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1) atomic.AddInt32(&calls, 1)
entered <- job.ID entered <- job.ID
<-release <-release
@@ -259,7 +259,7 @@ func TestRunDueQueueRerunsAfterFinish(t *testing.T) {
entered := make(chan int, 2) entered := make(chan int, 2)
release := make(chan struct{}) release := make(chan struct{})
var calls int32 var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1) atomic.AddInt32(&calls, 1)
entered <- job.ID entered <- job.ID
<-release <-release
@@ -314,7 +314,7 @@ func TestRunDueQueueDrainsMultipleOverlaps(t *testing.T) {
release := make(chan struct{}) release := make(chan struct{})
var calls int32 var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
if atomic.LoadInt32(&calls) == 0 { if atomic.LoadInt32(&calls) == 0 {
<-release <-release
} }
@@ -366,7 +366,7 @@ func TestRunDuePerJobQueueOverridesGlobalSkip(t *testing.T) {
entered := make(chan int, 2) entered := make(chan int, 2)
release := make(chan struct{}) release := make(chan struct{})
var calls int32 var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1) atomic.AddInt32(&calls, 1)
entered <- job.ID entered <- job.ID
<-release <-release
@@ -416,7 +416,7 @@ func TestRunDuePerJobSkipOverridesGlobalQueue(t *testing.T) {
entered := make(chan int, 2) entered := make(chan int, 2)
release := make(chan struct{}) release := make(chan struct{})
var calls int32 var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1) atomic.AddInt32(&calls, 1)
entered <- job.ID entered <- job.ID
<-release <-release
@@ -464,7 +464,7 @@ func TestRunDueEmptyOverlapInheritsGlobal(t *testing.T) {
entered := make(chan int, 2) entered := make(chan int, 2)
release := make(chan struct{}) release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
entered <- job.ID entered <- job.ID
<-release <-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
@@ -506,7 +506,7 @@ func TestRunNowSequentialGuard(t *testing.T) {
entered := make(chan int, 2) entered := make(chan int, 2)
release := make(chan struct{}) release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
entered <- job.ID entered <- job.ID
<-release <-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
@@ -547,7 +547,7 @@ func TestStartRunLockedRollbackOnSaveFailure(t *testing.T) {
t.Cleanup(func() { _ = os.Chmod(svc.store.Paths.JobsPath, 0o644) }) t.Cleanup(func() { _ = os.Chmod(svc.store.Paths.JobsPath, 0o644) })
var started int32 var started int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&started, 1) atomic.AddInt32(&started, 1)
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "OK"}, nil return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "OK"}, nil
} }
@@ -573,7 +573,7 @@ func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) {
entered := make(chan int, 2) entered := make(chan int, 2)
release := make(chan struct{}) release := make(chan struct{})
var calls int32 var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1) atomic.AddInt32(&calls, 1)
entered <- job.ID entered <- job.ID
<-release <-release
@@ -616,3 +616,21 @@ func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) {
t.Errorf("runner called %d time(s), want 1", got) t.Errorf("runner called %d time(s), want 1", got)
} }
} }
// TestEffectiveTimeout verifies the inherit-or-override resolution: a zero
// Job.TimeoutSeconds falls back to the global default, while a positive value
// overrides it.
func TestEffectiveTimeout(t *testing.T) {
svc := newTempService(t, nil)
svc.store.Config.DefaultTimeoutSeconds = 30
inherit := &domain.Job{TimeoutSeconds: 0}
if got, want := svc.effectiveTimeout(inherit), 30*time.Second; got != want {
t.Errorf("inherited timeout = %s, want %s", got, want)
}
own := &domain.Job{TimeoutSeconds: 5}
if got, want := svc.effectiveTimeout(own), 5*time.Second; got != want {
t.Errorf("per-job timeout = %s, want %s", got, want)
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ type Service struct {
// processes. ctx is the lifecycle context passed to runs; Start replaces it // processes. ctx is the lifecycle context passed to runs; Start replaces it
// with a cancelable context so Stop can abort in-flight runs, and until Start // with a cancelable context so Stop can abort in-flight runs, and until Start
// it is context.Background(). // it is context.Background().
runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string) (domain.RunRecord, error) runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string, timeout time.Duration) (domain.RunRecord, error)
ctx context.Context ctx context.Context
// sched is the timing loop installed by Start; cancel tears down ctx on Stop. // sched is the timing loop installed by Start; cancel tears down ctx on Stop.
+1 -1
View File
@@ -3,4 +3,4 @@ package app
// Version is the application version shown in the GUI and used by build // Version is the application version shown in the GUI and used by build
// scripts in artifact names. It is a var rather than a const so release builds // scripts in artifact names. It is a var rather than a const so release builds
// can override it with Go ldflags when CI tags a build. // can override it with Go ldflags when CI tags a build.
var Version = "0.11.5" var Version = "0.12.0"
+4 -1
View File
@@ -40,7 +40,10 @@ type Config struct {
NotifyOnFailure bool `json:"notify_on_failure,omitempty"` NotifyOnFailure bool `json:"notify_on_failure,omitempty"`
ExecutionMode ExecutionMode `json:"execution_mode,omitempty"` ExecutionMode ExecutionMode `json:"execution_mode,omitempty"`
OverlapPolicy OverlapPolicy `json:"overlap_policy,omitempty"` OverlapPolicy OverlapPolicy `json:"overlap_policy,omitempty"`
Paused bool `json:"paused,omitempty"` // DefaultTimeoutSeconds is the run timeout applied to jobs that do not set
// their own Job.TimeoutSeconds. It carries the formerly hard-coded 30s guard.
DefaultTimeoutSeconds int `json:"default_timeout_seconds,omitempty"`
Paused bool `json:"paused,omitempty"`
} }
// JobsFile is the on-disk shape of jobs.json. Wrapping the slice in a top-level // JobsFile is the on-disk shape of jobs.json. Wrapping the slice in a top-level
+13 -9
View File
@@ -6,13 +6,17 @@ package domain
// separate JobRuntime so the jobs file stays a clean, hand-editable record of // separate JobRuntime so the jobs file stays a clean, hand-editable record of
// configuration and never mixes in process-lifetime bookkeeping. // configuration and never mixes in process-lifetime bookkeeping.
type Job struct { type Job struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Folder string `json:"folder,omitempty"` Folder string `json:"folder,omitempty"`
Schedule string `json:"schedule"` Schedule string `json:"schedule"`
Command string `json:"command"` Command string `json:"command"`
Arguments string `json:"arguments,omitempty"` Arguments string `json:"arguments,omitempty"`
StartOnly bool `json:"start_only,omitempty"` StartOnly bool `json:"start_only,omitempty"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
OverlapPolicy string `json:"overlap_policy,omitempty"` OverlapPolicy string `json:"overlap_policy,omitempty"`
// TimeoutSeconds bounds how long a run may take before it is killed. 0 means
// "inherit the global Config.DefaultTimeoutSeconds", mirroring OverlapPolicy:
// normalizeJobs must leave 0 untouched rather than backfilling the default.
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
} }
+7 -9
View File
@@ -12,16 +12,14 @@ import (
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc" "gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
) )
const commandTimeout = 30 * time.Second
const commandWaitDelay = 2 * time.Second const commandWaitDelay = 2 * time.Second
func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string) (domain.RunRecord, error) { func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string, timeout time.Duration) (domain.RunRecord, error) {
started := time.Now() started := time.Now()
// Commands can hang forever if a script waits for input or a child process // Commands can hang forever if a script waits for input or a child process
// stalls. A fixed timeout is a conservative first guardrail for a desktop // stalls. The effective timeout is resolved by the caller (per-job value or
// scheduler; later it can become a per-job setting without changing the // the global default), keeping the runner ignorant of the global config.
// runner contract. runCtx, cancel := context.WithTimeout(ctx, timeout)
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel() defer cancel()
var output string var output string
@@ -49,7 +47,7 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
duration := time.Since(started).Round(time.Millisecond) duration := time.Since(started).Round(time.Millisecond)
durationMS = duration.Milliseconds() durationMS = duration.Milliseconds()
output = formatOutput(stdoutBuf.String(), stderrBuf.String()) output = formatOutput(stdoutBuf.String(), stderrBuf.String())
state, detail = runStateDetail(err, runCtx.Err(), duration) state, detail = runStateDetail(err, runCtx.Err(), duration, timeout)
} }
now := time.Now() now := time.Now()
@@ -106,12 +104,12 @@ func startOnlyOutput(job domain.Job, pid int) string {
return builder.String() return builder.String()
} }
func runStateDetail(err error, runErr error, duration time.Duration) (string, string) { func runStateDetail(err error, runErr error, duration time.Duration, timeout time.Duration) (string, string) {
if err == nil { if err == nil {
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration) return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
} }
if errors.Is(runErr, context.DeadlineExceeded) { if errors.Is(runErr, context.DeadlineExceeded) {
return "Failed", fmt.Sprintf("Timed out after %s", commandTimeout) return "Failed", fmt.Sprintf("Timed out after %s", timeout)
} }
if errors.Is(err, exec.ErrWaitDelay) { if errors.Is(err, exec.ErrWaitDelay) {
return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay) return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay)
+65 -9
View File
@@ -27,7 +27,7 @@ func TestRunJobLogFileAllHeaders(t *testing.T) {
Command: echoCommand("header test output"), Command: echoCommand("header test output"),
} }
record, err := RunJob(context.Background(), &job, "Schedule", logsDir) record, err := RunJob(context.Background(), &job, "Schedule", logsDir, 30*time.Second)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -77,7 +77,7 @@ func TestRunJobRecordFields(t *testing.T) {
Command: echoCommand("record field check"), Command: echoCommand("record field check"),
} }
record, err := RunJob(context.Background(), &job, "Schedule", t.TempDir()) record, err := RunJob(context.Background(), &job, "Schedule", t.TempDir(), 30*time.Second)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -164,7 +164,7 @@ func TestRunJobWritesLogFile(t *testing.T) {
Command: echoCommand("hello from test"), Command: echoCommand("hello from test"),
} }
record, err := RunJob(context.Background(), &job, "Manual", logsDir) record, err := RunJob(context.Background(), &job, "Manual", logsDir, 30*time.Second)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -202,7 +202,7 @@ func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) {
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`, Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
} }
record, err := RunJob(context.Background(), &job, "Manual", logsDir) record, err := RunJob(context.Background(), &job, "Manual", logsDir, 30*time.Second)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -234,7 +234,7 @@ func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) {
Command: scriptPath, Command: scriptPath,
} }
record, err := RunJob(context.Background(), &job, "Manual", logsDir) record, err := RunJob(context.Background(), &job, "Manual", logsDir, 30*time.Second)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -259,7 +259,7 @@ func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
Arguments: "/C\necho separate arguments ok", Arguments: "/C\necho separate arguments ok",
} }
record, err := RunJob(context.Background(), &job, "Manual", logsDir) record, err := RunJob(context.Background(), &job, "Manual", logsDir, 30*time.Second)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -285,7 +285,7 @@ func TestRunJobFailsOnNonZeroExitCode(t *testing.T) {
job.Arguments = "/C\nexit /b 1" job.Arguments = "/C\nexit /b 1"
} }
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir()) record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 30*time.Second)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -312,7 +312,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
StartOnly: true, StartOnly: true,
} }
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir()) record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 30*time.Second)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -336,7 +336,7 @@ func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
StartOnly: true, StartOnly: true,
} }
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir()) record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 30*time.Second)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -348,3 +348,59 @@ func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
} }
} }
func TestRunJobTimesOut(t *testing.T) {
command := "sh"
arguments := "-c\nsleep 5"
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
// timeout waits ~5s; ping to localhost is a portable stall on hosts where
// timeout refuses to run without an interactive console.
arguments = "/C\nping -n 6 127.0.0.1 >NUL"
}
job := domain.Job{
ID: 50,
Name: "Timeout Test",
Command: command,
Arguments: arguments,
}
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 100*time.Millisecond)
if err != nil {
t.Fatal(err)
}
if record.State != "Failed" {
t.Fatalf("expected timed-out job to fail, got state %q detail %q", record.State, record.Detail)
}
if !strings.Contains(record.Detail, "Timed out after 100ms") {
t.Fatalf("expected timeout detail with the effective timeout, got %q", record.Detail)
}
}
func TestRunJobStartOnlyIgnoresTimeout(t *testing.T) {
command := "sh"
arguments := "-c\nsleep 5"
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
arguments = "/C\nping -n 6 127.0.0.1 >NUL"
}
job := domain.Job{
ID: 51,
Name: "Start Only Timeout",
Command: command,
Arguments: arguments,
StartOnly: true,
}
// A tiny run timeout must not affect StartOnly jobs: they never wait on the
// timed run context, so the launch succeeds regardless.
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), time.Millisecond)
if err != nil {
t.Fatal(err)
}
if record.State != "OK" {
t.Fatalf("expected start-only job to be OK despite tiny timeout, got state %q detail %q", record.State, record.Detail)
}
if !strings.Contains(record.Detail, "not waiting for process exit") {
t.Fatalf("expected start-only detail, got %q", record.Detail)
}
}
+5
View File
@@ -78,6 +78,8 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
NotifyOnFailure: true, NotifyOnFailure: true,
ExecutionMode: domain.ExecutionModeParallel, ExecutionMode: domain.ExecutionModeParallel,
OverlapPolicy: domain.OverlapPolicySkip, OverlapPolicy: domain.OverlapPolicySkip,
DefaultTimeoutSeconds: 30,
} }
if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) { if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) {
@@ -112,6 +114,9 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
if config.OverlapPolicy == "" { if config.OverlapPolicy == "" {
config.OverlapPolicy = domain.OverlapPolicySkip config.OverlapPolicy = domain.OverlapPolicySkip
} }
if config.DefaultTimeoutSeconds <= 0 {
config.DefaultTimeoutSeconds = 30
}
return config, nil return config, nil
} }
+3
View File
@@ -168,6 +168,9 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
if got.MaxLogAgeDays != 30 { if got.MaxLogAgeDays != 30 {
t.Errorf("default MaxLogAgeDays = %d, want 30", got.MaxLogAgeDays) t.Errorf("default MaxLogAgeDays = %d, want 30", got.MaxLogAgeDays)
} }
if got.DefaultTimeoutSeconds != 30 {
t.Errorf("default DefaultTimeoutSeconds = %d, want 30", got.DefaultTimeoutSeconds)
}
// The function must have written the defaults to gosentry.json. // The function must have written the defaults to gosentry.json.
if _, err := os.Stat(paths.ConfigPath); err != nil { if _, err := os.Stat(paths.ConfigPath); err != nil {
t.Errorf("gosentry.json should have been created: %v", err) t.Errorf("gosentry.json should have been created: %v", err)
+20 -1
View File
@@ -2,6 +2,7 @@ package ui
import ( import (
"fmt" "fmt"
"strconv"
"strings" "strings"
"gitea.mixdep.ru/mix/gosentry/src/domain" "gitea.mixdep.ru/mix/gosentry/src/domain"
@@ -53,6 +54,11 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
overlapSelected = current.OverlapPolicy overlapSelected = current.OverlapPolicy
} }
overlapSelect.SetSelected(overlapSelected) overlapSelect.SetSelected(overlapSelected)
timeoutEntry := widget.NewEntry()
timeoutEntry.SetPlaceHolder("Empty = use global default")
if current.TimeoutSeconds > 0 {
timeoutEntry.SetText(strconv.Itoa(current.TimeoutSeconds))
}
form := dialog.NewForm( form := dialog.NewForm(
title, title,
@@ -66,6 +72,7 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
widget.NewFormItem("Arguments", argumentsEntry), widget.NewFormItem("Arguments", argumentsEntry),
widget.NewFormItem("", startOnly), widget.NewFormItem("", startOnly),
widget.NewFormItem("Overlap policy", overlapSelect), widget.NewFormItem("Overlap policy", overlapSelect),
widget.NewFormItem("Timeout (s)", timeoutEntry),
widget.NewFormItem("", enabled), widget.NewFormItem("", enabled),
}, },
func(saved bool) { func(saved bool) {
@@ -82,6 +89,17 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
dialog.ShowError(fmt.Errorf("invalid schedule: %w", err), w) dialog.ShowError(fmt.Errorf("invalid schedule: %w", err), w)
return return
} }
// An empty timeout inherits the global default (0); any entry must be a
// positive whole number of seconds.
timeoutSeconds := 0
if trimmed := strings.TrimSpace(timeoutEntry.Text); trimmed != "" {
parsed, err := strconv.Atoi(trimmed)
if err != nil || parsed <= 0 {
dialog.ShowError(fmt.Errorf("timeout must be a positive number of seconds, or empty to use the global default"), w)
return
}
timeoutSeconds = parsed
}
current.Name = strings.TrimSpace(name.Text) current.Name = strings.TrimSpace(name.Text)
current.Folder = strings.TrimSpace(folderEntry.Text) current.Folder = strings.TrimSpace(folderEntry.Text)
current.Schedule = strings.TrimSpace(scheduleEntry.Text) current.Schedule = strings.TrimSpace(scheduleEntry.Text)
@@ -93,6 +111,7 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
if current.OverlapPolicy == overlapPolicyInherit { if current.OverlapPolicy == overlapPolicyInherit {
current.OverlapPolicy = "" current.OverlapPolicy = ""
} }
current.TimeoutSeconds = timeoutSeconds
// The dialog only edits durable configuration. Runtime status is // The dialog only edits durable configuration. Runtime status is
// initialized (new jobs) or updated (edits) by the caller against the // initialized (new jobs) or updated (edits) by the caller against the
// runtime map, keyed by job ID. // runtime map, keyed by job ID.
@@ -100,6 +119,6 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
}, },
w, w,
) )
form.Resize(fyne.NewSize(640, 460)) form.Resize(fyne.NewSize(640, 500))
form.Show() form.Show()
} }
+3 -3
View File
@@ -72,9 +72,9 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
schedulerPaused := svc.Store().Config.Paused schedulerPaused := svc.Store().Config.Paused
filteredJobs := filteredJobIndexes(jobs, selectedFolder) filteredJobs := filteredJobIndexes(jobs, selectedFolder)
dp := newDetailsPanel(job{}, &domain.JobRuntime{}, svc.Store().Config.OverlapPolicy) dp := newDetailsPanel(job{}, &domain.JobRuntime{}, svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
if selected >= 0 { if selected >= 0 {
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy) dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
} else { } else {
dp.clear() dp.clear()
} }
@@ -87,7 +87,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
return return
} }
selected = index selected = index
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy) dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
} }
// list and folderSelect are declared early so closures below can reference // list and folderSelect are declared early so closures below can reference
+10 -5
View File
@@ -22,6 +22,7 @@ type detailsPanel struct {
arguments *widget.Label arguments *widget.Label
runMode *widget.Label runMode *widget.Label
overlapPolicy *widget.Label overlapPolicy *widget.Label
timeout *widget.Label
lastRun *widget.Label lastRun *widget.Label
nextRun *widget.Label nextRun *widget.Label
state *widget.Label state *widget.Label
@@ -34,7 +35,7 @@ type detailsPanel struct {
selectedLogs []event selectedLogs []event
} }
func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy) *detailsPanel { func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy, globalTimeout int) *detailsPanel {
d := &detailsPanel{ d := &detailsPanel{
title: widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), title: widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
folder: newJobDetailLabel(""), folder: newJobDetailLabel(""),
@@ -43,6 +44,7 @@ func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy do
arguments: newJobDetailLabel(""), arguments: newJobDetailLabel(""),
runMode: newJobDetailLabel(""), runMode: newJobDetailLabel(""),
overlapPolicy: newJobDetailLabel(""), overlapPolicy: newJobDetailLabel(""),
timeout: newJobDetailLabel(""),
lastRun: newJobDetailLabel(""), lastRun: newJobDetailLabel(""),
nextRun: newJobDetailLabel(""), nextRun: newJobDetailLabel(""),
state: newJobDetailLabel(""), state: newJobDetailLabel(""),
@@ -69,11 +71,11 @@ func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy do
item.(*widget.Label).SetText(app.EventLine(d.selectedLogs[id])) item.(*widget.Label).SetText(app.EventLine(d.selectedLogs[id]))
}, },
) )
d.update(firstJob, rt, globalOverlapPolicy) d.update(firstJob, rt, globalOverlapPolicy, globalTimeout)
return d return d
} }
func (d *detailsPanel) update(j job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy) { func (d *detailsPanel) update(j job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy, globalTimeout int) {
d.title.SetText(j.Name) d.title.SetText(j.Name)
d.folder.SetText(app.DisplayFolder(j.Folder)) d.folder.SetText(app.DisplayFolder(j.Folder))
d.schedule.SetText(j.Schedule) d.schedule.SetText(j.Schedule)
@@ -81,6 +83,7 @@ func (d *detailsPanel) update(j job, rt *domain.JobRuntime, globalOverlapPolicy
d.arguments.SetText(app.DisplayArguments(j.Arguments)) d.arguments.SetText(app.DisplayArguments(j.Arguments))
d.runMode.SetText(app.DisplayRunMode(j)) d.runMode.SetText(app.DisplayRunMode(j))
d.overlapPolicy.SetText(app.DisplayOverlapPolicy(j, globalOverlapPolicy)) d.overlapPolicy.SetText(app.DisplayOverlapPolicy(j, globalOverlapPolicy))
d.timeout.SetText(app.DisplayTimeout(j, globalTimeout))
d.lastRun.SetText(rt.LastRun) d.lastRun.SetText(rt.LastRun)
d.nextRun.SetText(rt.NextRun) d.nextRun.SetText(rt.NextRun)
d.state.SetText(rt.LastState) d.state.SetText(rt.LastState)
@@ -101,6 +104,7 @@ func (d *detailsPanel) clear() {
d.arguments.SetText("") d.arguments.SetText("")
d.runMode.SetText("") d.runMode.SetText("")
d.overlapPolicy.SetText("") d.overlapPolicy.SetText("")
d.timeout.SetText("")
d.lastRun.SetText("") d.lastRun.SetText("")
d.nextRun.SetText("") d.nextRun.SetText("")
d.state.SetText("") d.state.SetText("")
@@ -121,8 +125,9 @@ func (d *detailsPanel) container() fyne.CanvasObject {
detailRowPair(capW, "Folder", d.folder, "Schedule", d.schedule), detailRowPair(capW, "Folder", d.folder, "Schedule", d.schedule),
detailRowPair(capW, "Command", d.command, "Arguments", d.arguments), detailRowPair(capW, "Command", d.command, "Arguments", d.arguments),
detailRowPair(capW, "Run mode", d.runMode, "Overlap policy", d.overlapPolicy), detailRowPair(capW, "Run mode", d.runMode, "Overlap policy", d.overlapPolicy),
detailRowPair(capW, "Timeout", d.timeout, "State", d.state),
detailRowPair(capW, "Last run", d.lastRun, "Next run", d.nextRun), detailRowPair(capW, "Last run", d.lastRun, "Next run", d.nextRun),
detailRowPair(capW, "State", d.state, "Statistics", d.stats), detailRow(capW, "Statistics", d.stats),
) )
top := container.NewVBox( top := container.NewVBox(
d.title, d.title,
@@ -160,7 +165,7 @@ func activityRowsHeight(rows int) float32 {
func detailCaptionWidth() float32 { func detailCaptionWidth() float32 {
captions := []string{ captions := []string{
"Folder", "Schedule", "Command", "Arguments", "Run mode", "Folder", "Schedule", "Command", "Arguments", "Run mode",
"Overlap policy", "Last run", "Next run", "State", "Statistics", "Overlap policy", "Timeout", "Last run", "Next run", "State", "Statistics",
} }
var width float32 var width float32
for _, c := range captions { for _, c := range captions {
+9 -8
View File
@@ -24,14 +24,15 @@ func newTestService(t *testing.T) *app.Service {
LogsDir: filepath.Join(dir, "logs"), LogsDir: filepath.Join(dir, "logs"),
}, },
Config: domain.Config{ Config: domain.Config{
JobsDir: ".", JobsDir: ".",
LogsDir: "logs", LogsDir: "logs",
MaxLogFiles: 100, MaxLogFiles: 100,
MaxLogAgeDays: 30, MaxLogAgeDays: 30,
ExecutionMode: domain.ExecutionModeParallel, ExecutionMode: domain.ExecutionModeParallel,
OverlapPolicy: domain.OverlapPolicySkip, OverlapPolicy: domain.OverlapPolicySkip,
KeepRunningInTray: true, DefaultTimeoutSeconds: 30,
NotifyOnFailure: true, KeepRunningInTray: true,
NotifyOnFailure: true,
}, },
} }
return app.NewService(store, nil) return app.NewService(store, nil)
+11 -1
View File
@@ -73,6 +73,9 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
) )
overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy)) overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy))
overlapPolicySelect.OnChanged = func(string) { updateSaveState() } overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
defaultTimeout := widget.NewEntry()
defaultTimeout.SetText(strconv.Itoa(store.Config.DefaultTimeoutSeconds))
defaultTimeout.OnChanged = func(string) { updateSaveState() }
jobsDir := widget.NewEntry() jobsDir := widget.NewEntry()
jobsDir.SetText(store.Config.JobsDir) jobsDir.SetText(store.Config.JobsDir)
jobsDir.OnChanged = func(string) { updateSaveState() } jobsDir.OnChanged = func(string) { updateSaveState() }
@@ -116,6 +119,11 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
settingsStatus.SetText("Logs directory is required") settingsStatus.SetText("Logs directory is required")
return return
} }
timeout, err := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text))
if err != nil || timeout <= 0 {
settingsStatus.SetText("Default timeout must be a positive number")
return
}
// Build the new config from the form and hand it to the Service, which // Build the new config from the form and hand it to the Service, which
// validates it, persists config and jobs to the (possibly new) directory, // validates it, persists config and jobs to the (possibly new) directory,
// and runs log cleanup so tightened retention limits take effect at once. // and runs log cleanup so tightened retention limits take effect at once.
@@ -129,6 +137,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
config.NotifyOnFailure = notifications.Checked config.NotifyOnFailure = notifications.Checked
config.ExecutionMode = domain.ExecutionMode(executionModeSelect.Selected) config.ExecutionMode = domain.ExecutionMode(executionModeSelect.Selected)
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected) config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
config.DefaultTimeoutSeconds = timeout
if err := svc.UpdateSettings(config); err != nil { if err := svc.UpdateSettings(config); err != nil {
settingsStatus.SetText("Save failed: " + err.Error()) settingsStatus.SetText("Save failed: " + err.Error())
return return
@@ -155,6 +164,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
notifications.Checked != c.NotifyOnFailure || notifications.Checked != c.NotifyOnFailure ||
executionModeSelect.Selected != string(c.ExecutionMode) || executionModeSelect.Selected != string(c.ExecutionMode) ||
overlapPolicySelect.Selected != string(c.OverlapPolicy) || overlapPolicySelect.Selected != string(c.OverlapPolicy) ||
strings.TrimSpace(defaultTimeout.Text) != strconv.Itoa(c.DefaultTimeoutSeconds) ||
strings.TrimSpace(jobsDir.Text) != c.JobsDir || strings.TrimSpace(jobsDir.Text) != c.JobsDir ||
strings.TrimSpace(logsDir.Text) != c.LogsDir || strings.TrimSpace(logsDir.Text) != c.LogsDir ||
strings.TrimSpace(maxLogFiles.Text) != strconv.Itoa(c.MaxLogFiles) || strings.TrimSpace(maxLogFiles.Text) != strconv.Itoa(c.MaxLogFiles) ||
@@ -188,6 +198,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
widget.NewLabelWithStyle("Queue", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("Queue", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
settingsRow("Execution mode", container.New(minWidthLayout{width: settingsControlWidth}, executionModeSelect)), settingsRow("Execution mode", container.New(minWidthLayout{width: settingsControlWidth}, executionModeSelect)),
settingsRow("Default overlap policy", container.New(minWidthLayout{width: settingsControlWidth}, overlapPolicySelect)), settingsRow("Default overlap policy", container.New(minWidthLayout{width: settingsControlWidth}, overlapPolicySelect)),
settingsRow("Default timeout (s)", container.New(minWidthLayout{width: settingsControlWidth}, defaultTimeout)),
), ),
) )
rightColumn := container.NewVBox( rightColumn := container.NewVBox(
@@ -293,4 +304,3 @@ func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
captionBox := container.New(minWidthLayout{width: settingsLabelWidth}, caption) captionBox := container.New(minWidthLayout{width: settingsLabelWidth}, caption)
return container.NewBorder(nil, nil, captionBox, nil, value) return container.NewBorder(nil, nil, captionBox, nil, value)
} }