Skip to content

Commit 5afc5e6

Browse files
Merge remote-tracking branch 'origin/v5' into scanner/fix-137
Signed-off-by: scanner <280983584+hivecommons-hive[bot]@users.noreply.github.com>
2 parents 7c08fcc + 4126d8b commit 5afc5e6

9 files changed

Lines changed: 223 additions & 18 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Crux's command log (`GET /v1/runs/{id}/cmdlog`) no longer pins a follower's `next_seq` forever ([#17410](https://github.com/hivecommons/hive/issues/17410)). A command that ended `unfinished` (the launcher failed without an exit the runtime could read) is now terminal, so `next_seq` moves past it; a command left in flight by a runner that died is ended `unfinished` when its log is next opened, which `crux serve` now does for every run it reconciles at start; and `more` is never true on a page whose `next_seq` did not move, so a "page while `more`" loop such as `crux cmdlog list --all` can no longer spin on the same request.

‎src/crux/api/cmdlog.go‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,9 @@ type CommandOutput struct {
6767
Truncated bool `json:"truncated,omitempty"`
6868
}
6969

70-
// CommandRecord is one dispatched command. ExitCode and EndedAt are
71-
// absent while the command is unfinished.
70+
// CommandRecord is one dispatched command. EndedAt is absent while the
71+
// command is in flight; ExitCode is absent whenever the exit is not known,
72+
// so an unfinished record can carry EndedAt without ExitCode.
7273
type CommandRecord struct {
7374
Seq int64 `json:"seq"`
7475
Command []string `json:"command"`
@@ -88,8 +89,11 @@ type CommandRecord struct {
8889
// CommandPage is GET /v1/runs/{id}/cmdlog. NextSeq is the seq to pass as
8990
// since_seq for the following page; More says one exists now. A caller
9091
// following a live run keeps polling with NextSeq after More is false.
91-
// NextSeq never advances past an unfinished record, so a follower
92-
// re-receives it until it is terminal (idempotent by seq).
92+
// NextSeq never advances past a record with no EndedAt, so a follower
93+
// re-receives an in-flight command until it ends (idempotent by seq); a
94+
// record with EndedAt is terminal whatever its Status. More is never set
95+
// on a page whose NextSeq equals the since_seq it answered, so paging
96+
// while More is true always makes progress.
9397
type CommandPage struct {
9498
Run string `json:"run"`
9599
Commands []CommandRecord `json:"commands"`

‎src/crux/cli/serve.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,8 @@ func (e *Env) openRuns(workDir, path string) (*runs.Service, error) {
214214
}
215215
svc := runs.New(store, backend, cfg)
216216
// S4: every dispatched command is appended to <run dir>/commands.jsonl
217-
// by the runner, with the default head/tail output policy.
217+
// by the runner, with the default head/tail output policy. It is set
218+
// before Reconcile so a command left in flight gets its ended line.
218219
svc.CmdLog = cmdlog.FileOpener(cmdlog.Policy{})
219220
// S5: turn signals go to <run dir>/signals.jsonl. The book is set
220221
// before Reconcile so a run left in flight gets its done signal.

‎src/crux/cmdlog/cmdlog.go‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
//
66
// The log is one commands.jsonl file in the run's directory. Each command
77
// contributes a "started" line when it is dispatched and an "ended" line
8-
// when its exit is known; a command with no "ended" line is unfinished,
9-
// never guessed. Readers fold the two into one api.CommandRecord per seq.
8+
// once the runtime stops waiting on it, carrying the exit when it is
9+
// known; a command with no "ended" line is in flight and reads as
10+
// unfinished, never guessed. Readers fold the two into one
11+
// api.CommandRecord per seq.
1012
//
1113
// Honest scope: a record says how the command was observed in its
1214
// capture field. The S2 runner records what it hands to the sandbox
@@ -34,6 +36,10 @@ const (
3436
EventEnded = "ended"
3537
)
3638

39+
// OrphanReason is the reason on the unfinished ended line Open appends
40+
// for a command whose started line an earlier writer never ended.
41+
const OrphanReason = "the log was reopened with no exit recorded for this command (the runtime restarted, or its ended write failed); the exit is not knowable"
42+
3743
// Output policy defaults: how much of each stream a record keeps. The
3844
// full output stays with the run (runs.StdoutArtifact); the record keeps
3945
// enough of both ends to tell what happened without re-reading it.

‎src/crux/cmdlog/cmdlog_test.go‎

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,57 @@ func TestOpenResumesTheSeqCounter(t *testing.T) {
120120
if err != nil || seq != 4 {
121121
t.Fatalf("resumed seq = %d, %v", seq, err)
122122
}
123-
// The earlier log's open commands are not the new log's to end.
123+
// Ended still refuses a seq this log never started, even one Open ended.
124124
if err := second.Ended(1, End{}); !errors.Is(err, ErrUnknownSeq) {
125125
t.Fatalf("Ended(1) on resumed = %v", err)
126126
}
127127
}
128128

129+
// A started line no writer will ever end (its runner died) is ended
130+
// unfinished by the next Open, so it cannot pin NextSeq for the rest of
131+
// the run (#17410).
132+
func TestOpenEndsACommandAnEarlierWriterLeftOpen(t *testing.T) {
133+
dir := t.TempDir()
134+
c := newClock()
135+
first := openLog(t, dir, Policy{Now: c.tick})
136+
orphan, err := first.Started(Start{Command: []string{"make", "test"}})
137+
if err != nil {
138+
t.Fatal(err)
139+
}
140+
seq, err := first.Started(Start{Command: []string{"echo"}})
141+
if err != nil {
142+
t.Fatal(err)
143+
}
144+
if err := first.Ended(seq, End{ExitCode: intp(0)}); err != nil {
145+
t.Fatal(err)
146+
}
147+
second := openLog(t, dir, Policy{Now: c.tick})
148+
page, err := Read(dir, 0, 0)
149+
if err != nil || len(page.Commands) != 2 || page.NextSeq != seq || page.More {
150+
t.Fatalf("page = %+v, %v", page, err)
151+
}
152+
rec := page.Commands[0]
153+
if rec.Seq != orphan || rec.Status != api.CommandUnfinished || rec.EndedAt == nil || rec.ExitCode != nil || rec.Reason != OrphanReason || rec.DurationMs == nil || *rec.DurationMs != 750 {
154+
t.Fatalf("orphan = %+v", rec)
155+
}
156+
if page.Commands[1].Status != api.CommandCompleted {
157+
t.Fatalf("ended command = %+v", page.Commands[1])
158+
}
159+
// The orphan is ended once: reopening again appends nothing.
160+
path := filepath.Join(dir, FileName)
161+
before, err := os.ReadFile(path)
162+
if err != nil {
163+
t.Fatal(err)
164+
}
165+
openLog(t, dir, Policy{})
166+
if after, err := os.ReadFile(path); err != nil || string(after) != string(before) {
167+
t.Fatalf("second reopen rewrote the log: %q, %v", after, err)
168+
}
169+
if next, err := second.Started(Start{Command: []string{"y"}}); err != nil || next != seq+1 {
170+
t.Fatalf("resumed seq = %d, %v", next, err)
171+
}
172+
}
173+
129174
func TestReadPagesWithSinceSeqAndLimit(t *testing.T) {
130175
dir := t.TempDir()
131176
l := openLog(t, dir, Policy{})
@@ -202,6 +247,71 @@ func TestReadNextSeqStopsBeforeFirstUnfinished(t *testing.T) {
202247
}
203248
}
204249

250+
// An ended line makes a record terminal whatever its status: a command
251+
// ended unfinished (exit never known) must not hold NextSeq back (#17410).
252+
func TestReadNextSeqPassesAnEndedUnfinishedRecord(t *testing.T) {
253+
dir := t.TempDir()
254+
l := openLog(t, dir, Policy{})
255+
for i := range 4 {
256+
seq, err := l.Started(Start{Command: []string{"cmd", string(rune('a' + i))}})
257+
if err != nil {
258+
t.Fatal(err)
259+
}
260+
end := End{ExitCode: intp(0)}
261+
if i == 1 {
262+
end = End{Reason: "launcher error"}
263+
}
264+
if err := l.Ended(seq, end); err != nil {
265+
t.Fatal(err)
266+
}
267+
}
268+
page, err := Read(dir, 0, 0)
269+
if err != nil || len(page.Commands) != 4 || page.NextSeq != 4 || page.More {
270+
t.Fatalf("page = %+v, %v", page, err)
271+
}
272+
if rec := page.Commands[1]; rec.Status != api.CommandUnfinished || rec.EndedAt == nil || rec.ExitCode != nil {
273+
t.Fatalf("ended unfinished = %+v", rec)
274+
}
275+
page, err = Read(dir, 0, 2)
276+
if err != nil || len(page.Commands) != 2 || page.NextSeq != 2 || !page.More {
277+
t.Fatalf("page 1 = %+v, %v", page, err)
278+
}
279+
page, err = Read(dir, page.NextSeq, 2)
280+
if err != nil || len(page.Commands) != 2 || page.NextSeq != 4 || page.More {
281+
t.Fatalf("page 2 = %+v, %v", page, err)
282+
}
283+
}
284+
285+
// More is set only on a page whose cursor moved: with an in-flight command
286+
// first on the page, the documented "page while More" loop would otherwise
287+
// re-request the same since_seq forever (#17410).
288+
func TestReadMoreOnlyWhenNextSeqAdvances(t *testing.T) {
289+
dir := t.TempDir()
290+
l := openLog(t, dir, Policy{})
291+
for i := range 6 {
292+
seq, err := l.Started(Start{Command: []string{"cmd", string(rune('a' + i))}})
293+
if err != nil {
294+
t.Fatal(err)
295+
}
296+
if i == 1 {
297+
continue
298+
}
299+
if err := l.Ended(seq, End{ExitCode: intp(0)}); err != nil {
300+
t.Fatal(err)
301+
}
302+
}
303+
// Seq 2 is in flight. The first page still moves the cursor past seq 1.
304+
page, err := Read(dir, 0, 2)
305+
if err != nil || len(page.Commands) != 2 || page.NextSeq != 1 || !page.More {
306+
t.Fatalf("page 1 = %+v, %v", page, err)
307+
}
308+
// From there seq 2 leads the page and the cursor cannot move.
309+
page, err = Read(dir, page.NextSeq, 2)
310+
if err != nil || len(page.Commands) != 2 || page.Commands[0].Seq != 2 || page.NextSeq != 1 || page.More {
311+
t.Fatalf("pinned page = %+v, %v", page, err)
312+
}
313+
}
314+
205315
func TestOutputKeepsHeadAndTailPerPolicy(t *testing.T) {
206316
dir := t.TempDir()
207317
l := openLog(t, dir, Policy{HeadBytes: 4, TailBytes: 3})

‎src/crux/cmdlog/log.go‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import (
55
"encoding/json"
66
"errors"
77
"fmt"
8+
"maps"
89
"os"
910
"path/filepath"
11+
"slices"
1012
"sort"
1113
"sync"
1214
"time"
@@ -39,16 +41,43 @@ type Log struct {
3941

4042
// Open returns the Log for runDir, resuming the seq counter from a file
4143
// that already exists so a restarted runner never reuses a number.
44+
//
45+
// A run has one live writer at a time (a contained run dispatches one
46+
// command; a session's steps run one at a time), so a started seq with no
47+
// ended line on disk belongs to a writer that is gone and will never end
48+
// it. Open appends an unfinished ended line with OrphanReason for each,
49+
// so a crash leaves a terminal record instead of one that pins every
50+
// follower's NextSeq behind it for the rest of the run.
4251
func Open(runDir string, pol Policy) (*Log, error) {
4352
l := &Log{path: filepath.Join(runDir, FileName), pol: pol.defaults(), next: 1, open: map[int64]time.Time{}}
4453
lines, err := readLines(l.path)
4554
if err != nil && !errors.Is(err, ErrNotFound) {
4655
return nil, err
4756
}
57+
// orphans folds the lines the way Read does: a started line opens its
58+
// seq, a later ended line closes it.
59+
orphans := map[int64]time.Time{}
4860
for _, ln := range lines {
4961
if ln.Seq >= l.next {
5062
l.next = ln.Seq + 1
5163
}
64+
switch ln.Event {
65+
case EventStarted:
66+
orphans[ln.Seq] = ln.At
67+
case EventEnded:
68+
delete(orphans, ln.Seq)
69+
}
70+
}
71+
if len(orphans) == 0 {
72+
return l, nil
73+
}
74+
now := l.pol.Now()
75+
reason := l.pol.Scrub(OrphanReason)
76+
for _, seq := range slices.Sorted(maps.Keys(orphans)) {
77+
ms := now.Sub(orphans[seq]).Milliseconds()
78+
if err := l.append(line{Seq: seq, Event: EventEnded, At: now, DurationMs: &ms, Status: api.CommandUnfinished, Reason: reason}); err != nil {
79+
return nil, err
80+
}
5281
}
5382
return l, nil
5483
}
@@ -118,9 +147,13 @@ func (l *Log) append(ln line) error {
118147

119148
// Read folds runDir's log into records after sinceSeq (exclusive), at
120149
// most limit of them (DefaultPageSize when zero, MaxPageSize at most).
121-
// A started line with no ended line is an unfinished record. NextSeq never
122-
// advances past an unfinished record, so a follower re-receives it until
123-
// it is terminal (idempotent by seq).
150+
// A started line with no ended line is an unfinished record still in
151+
// flight. NextSeq never advances past an in-flight record, so a follower
152+
// re-receives it until its ended line lands (idempotent by seq); a record
153+
// with an ended line is terminal whatever its status, an unfinished one
154+
// whose exit was never known included. More is false on a page whose
155+
// NextSeq could not move past sinceSeq, so a pager never re-requests the
156+
// same page in a loop.
124157
func Read(runDir string, sinceSeq int64, limit int) (api.CommandPage, error) {
125158
lines, err := readLines(filepath.Join(runDir, FileName))
126159
if err != nil {
@@ -165,12 +198,13 @@ func Read(runDir string, sinceSeq int64, limit int) (api.CommandPage, error) {
165198
if n := len(page.Commands); n > 0 {
166199
page.NextSeq = page.Commands[n-1].Seq
167200
for _, rec := range page.Commands {
168-
if rec.Status == api.CommandUnfinished {
201+
if rec.EndedAt == nil {
169202
page.NextSeq = max(rec.Seq-1, sinceSeq)
170203
break
171204
}
172205
}
173206
}
207+
page.More = page.More && page.NextSeq > sinceSeq
174208
return page, nil
175209
}
176210

‎src/crux/runs/cmdlog_test.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ func TestLogStatusFollowsTheOutcome(t *testing.T) {
8787
if rec.Status != tc.want || (tc.exit == nil) != (rec.ExitCode == nil) || (tc.exit != nil && *tc.exit != *rec.ExitCode) || rec.EndedAt == nil {
8888
t.Fatalf("record = %+v want %s exit=%v", rec, tc.want, tc.exit)
8989
}
90+
// Every outcome is terminal, unfinished included: a follower moves on.
91+
if page.NextSeq != rec.Seq || page.More {
92+
t.Fatalf("page = %+v", page)
93+
}
9094
})
9195
}
9296
}
@@ -106,6 +110,34 @@ func TestCommandsWithoutALogIsAnEmptyPage(t *testing.T) {
106110
}
107111
}
108112

113+
// A command the previous process dispatched and never ended is ended
114+
// unfinished at restart, so a follower's next_seq is not pinned on it
115+
// forever (#17410).
116+
func TestReconcileEndsTheCommandARunLeftInFlight(t *testing.T) {
117+
f := withCmdLog(newFixture(t, blocking()))
118+
rec, _, err := f.svc.Store.Create(runstate.Record{Owner: "operator"})
119+
if err != nil {
120+
t.Fatal(err)
121+
}
122+
log, err := f.svc.CmdLog(f.svc.Store.Path(rec.ID))
123+
if err != nil {
124+
t.Fatal(err)
125+
}
126+
if _, err := log.Started(cmdlog.Start{Command: []string{"make"}}); err != nil {
127+
t.Fatal(err)
128+
}
129+
if _, err := f.svc.Reconcile(); err != nil {
130+
t.Fatal(err)
131+
}
132+
page, err := f.svc.Commands(rec.ID, 0, 0)
133+
if err != nil || len(page.Commands) != 1 || page.NextSeq != 1 || page.More {
134+
t.Fatalf("Commands = %+v, %v", page, err)
135+
}
136+
if got := page.Commands[0]; got.Status != api.CommandUnfinished || got.EndedAt == nil || got.ExitCode != nil || got.Reason != cmdlog.OrphanReason {
137+
t.Fatalf("record = %+v", got)
138+
}
139+
}
140+
109141
func TestAnOpenerFailureDoesNotStopTheRun(t *testing.T) {
110142
f := newFixture(t, newFakeLauncher(func(context.Context, sandbox.LaunchSpec) (sandbox.Result, error) {
111143
return sandbox.Result{Stdout: "ran"}, nil

‎src/crux/runs/service.go‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,8 @@ func (s *Service) Configured() bool { return s.Backend != nil }
176176

177177
// Reconcile marks every run left accepted, running or waiting by a previous
178178
// process unfinished, and with it any step (S6) that run still records as
179-
// running. Call it once at serve start, before accepting requests.
179+
// running and any command (S4) its log still records as in flight. Call it
180+
// once at serve start, before accepting requests.
180181
func (s *Service) Reconcile() ([]runstate.Record, error) {
181182
recs, err := s.Store.Reconcile(ReconcileReason)
182183
// A run the previous process left in flight ended unobserved; its done
@@ -185,6 +186,13 @@ func (s *Service) Reconcile() ([]runstate.Record, error) {
185186
if _, serr := (agentstep.Store{RunDir: s.Store.Path(rec.ID)}).Reconcile(ReconcileReason, s.Store.Now()); serr != nil && err == nil {
186187
err = serr
187188
}
189+
// Reopening its command log ends the command it left in flight
190+
// (cmdlog.Open), so a follower's next_seq is not pinned behind it
191+
// forever. Like every other cmdlog failure, an unopenable log is
192+
// skipped rather than holding up the restart.
193+
if s.CmdLog != nil {
194+
_, _ = s.CmdLog(s.Store.Path(rec.ID))
195+
}
188196
s.signalDone(rec.ID, api.SignalDoneData{Status: rec.Status}, rec.Reason)
189197
}
190198
return recs, err

‎src/docs/design/crux-work-services.md‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -296,9 +296,14 @@ started_at, ended_at?, duration_ms?, exit_code?, stdout?, stderr?}`;
296296
count always, and up to 4 KiB from each end of the text the runner kept.
297297
`status` is one of `completed` (exit 0), `failed` (non-zero exit,
298298
refusal or timeout), `unfinished` (the sandbox never reported an exit)
299-
or `cancelled`. A `started` line with no `ended` line is returned as
300-
`unfinished` with no `ended_at`; a status is never guessed. Paging:
301-
pass the previous page's `next_seq` as `since_seq` while `more` is true.
299+
or `cancelled`. A `started` line with no `ended` line is an in-flight
300+
command, returned as `unfinished` with no `ended_at`; a status is never
301+
guessed. Paging: pass the previous page's `next_seq` as `since_seq` while
302+
`more` is true. `next_seq` stops before the first record with no
303+
`ended_at`, so a follower re-receives an in-flight command until it ends;
304+
a record with `ended_at` is terminal whatever its `status`, `unfinished`
305+
included. `more` is never true on a page whose `next_seq` equals the
306+
`since_seq` it answered, so that loop always makes progress.
302307
Visibility follows the run — a project reads only its own runs' logs
303308
(403 otherwise), unknown run 404, bad `since_seq`/`limit` 400, a run that
304309
kept no log (or an older server) is an empty page, a corrupt line 500
@@ -321,8 +326,12 @@ and tails pass through `logscrub.ScrubString` before they are written.
321326
two lines per command: `{"event":"started","seq",…}` and
322327
`{"event":"ended","seq",…}`. Readers fold the pair; a stray `ended`
323328
without its `started` is dropped. Re-opening a run's log resumes `seq`
324-
after the highest one on disk and cannot end a command an earlier
325-
process left open — it stays `unfinished`, which is the truth.
329+
after the highest one on disk and ends every command an earlier writer
330+
left open with an `ended` line of status `unfinished` (reason
331+
`cmdlog.OrphanReason`, no exit code): a run has one live writer at a
332+
time, so that command's exit is never coming. `crux serve` reopens the
333+
log of every run it reconciles at start, so a crash mid-command leaves a
334+
terminal record rather than one that pins `next_seq` forever.
326335

327336
**Under-counting, stated plainly.** Every record carries `capture`; in
328337
S4 the only value written is `"runner"`, which means *the command the

0 commit comments

Comments
 (0)