From 082fe343ba52966f9a2fdda9bc90d8165ea6a6c6 Mon Sep 17 00:00:00 2001 From: scanner <280983584+hivecommons-hive[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:04:26 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(crux):=20cmdlog=20next=5Fseq?= =?UTF-8?q?=20never=20skips=20an=20unfinished=20command's=20ended=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ended lines share the started seq and were filtered by since_seq before folding, so followers never saw terminal status. Fold all lines first, and stop next_seq before the first unfinished record. Closes #17398 Signed-off-by: scanner <280983584+hivecommons-hive[bot]@users.noreply.github.com> --- changelog.d/fixed-cmdlog-follow-unfinished.md | 1 + src/crux/api/cmdlog.go | 2 + src/crux/cmdlog/cmdlog_test.go | 41 +++++++++++++++++++ src/crux/cmdlog/log.go | 16 ++++++-- 4 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fixed-cmdlog-follow-unfinished.md diff --git a/changelog.d/fixed-cmdlog-follow-unfinished.md b/changelog.d/fixed-cmdlog-follow-unfinished.md new file mode 100644 index 0000000000..8dfbd5fff4 --- /dev/null +++ b/changelog.d/fixed-cmdlog-follow-unfinished.md @@ -0,0 +1 @@ +- crux cmdlog: a follower polling with next_seq now receives a command's ended record instead of seeing it as unfinished forever (#17398). diff --git a/src/crux/api/cmdlog.go b/src/crux/api/cmdlog.go index eea88d9c36..42c40d8730 100644 --- a/src/crux/api/cmdlog.go +++ b/src/crux/api/cmdlog.go @@ -88,6 +88,8 @@ type CommandRecord struct { // CommandPage is GET /v1/runs/{id}/cmdlog. NextSeq is the seq to pass as // since_seq for the following page; More says one exists now. A caller // following a live run keeps polling with NextSeq after More is false. +// NextSeq never advances past an unfinished record, so a follower +// re-receives it until it is terminal (idempotent by seq). type CommandPage struct { Run string `json:"run"` Commands []CommandRecord `json:"commands"` diff --git a/src/crux/cmdlog/cmdlog_test.go b/src/crux/cmdlog/cmdlog_test.go index 02edb6fd75..9c0c9c880a 100644 --- a/src/crux/cmdlog/cmdlog_test.go +++ b/src/crux/cmdlog/cmdlog_test.go @@ -161,6 +161,47 @@ func TestReadPagesWithSinceSeqAndLimit(t *testing.T) { } } +func TestReadFollowerSeesEndedLineOfUnfinishedCommand(t *testing.T) { + dir := t.TempDir() + l := openLog(t, dir, Policy{}) + seq, err := l.Started(Start{Command: []string{"make", "test"}}) + if err != nil { + t.Fatal(err) + } + page, err := Read(dir, 0, 0) + if err != nil || len(page.Commands) != 1 || page.Commands[0].Status != api.CommandUnfinished || page.NextSeq != 0 { + t.Fatalf("unfinished page = %+v, %v", page, err) + } + if err := l.Ended(seq, End{ExitCode: intp(0)}); err != nil { + t.Fatal(err) + } + page, err = Read(dir, page.NextSeq, 0) + if err != nil || len(page.Commands) != 1 || page.Commands[0].Status != api.CommandCompleted || page.NextSeq != seq { + t.Fatalf("terminal page = %+v, %v", page, err) + } +} + +func TestReadNextSeqStopsBeforeFirstUnfinished(t *testing.T) { + dir := t.TempDir() + l := openLog(t, dir, Policy{}) + for i := range 3 { + seq, err := l.Started(Start{Command: []string{"cmd", string(rune('a' + i))}}) + if err != nil { + t.Fatal(err) + } + if i == 1 { + continue + } + if err := l.Ended(seq, End{ExitCode: intp(0)}); err != nil { + t.Fatal(err) + } + } + page, err := Read(dir, 0, 0) + if err != nil || len(page.Commands) != 3 || page.NextSeq != 1 || page.Commands[1].Status != api.CommandUnfinished { + t.Fatalf("mixed page = %+v, %v", page, err) + } +} + func TestOutputKeepsHeadAndTailPerPolicy(t *testing.T) { dir := t.TempDir() l := openLog(t, dir, Policy{HeadBytes: 4, TailBytes: 3}) diff --git a/src/crux/cmdlog/log.go b/src/crux/cmdlog/log.go index 47714eba18..1cb24c5f86 100644 --- a/src/crux/cmdlog/log.go +++ b/src/crux/cmdlog/log.go @@ -118,7 +118,9 @@ func (l *Log) append(ln line) error { // Read folds runDir's log into records after sinceSeq (exclusive), at // most limit of them (DefaultPageSize when zero, MaxPageSize at most). -// A started line with no ended line is an unfinished record. +// A started line with no ended line is an unfinished record. NextSeq never +// advances past an unfinished record, so a follower re-receives it until +// it is terminal (idempotent by seq). func Read(runDir string, sinceSeq int64, limit int) (api.CommandPage, error) { lines, err := readLines(filepath.Join(runDir, FileName)) if err != nil { @@ -132,9 +134,6 @@ func Read(runDir string, sinceSeq int64, limit int) (api.CommandPage, error) { } bySeq := map[int64]*api.CommandRecord{} for _, ln := range lines { - if ln.Seq <= sinceSeq { - continue - } switch ln.Event { case EventStarted: bySeq[ln.Seq] = &api.CommandRecord{ @@ -153,6 +152,9 @@ func Read(runDir string, sinceSeq int64, limit int) (api.CommandPage, error) { } all := make([]api.CommandRecord, 0, len(bySeq)) for _, rec := range bySeq { + if rec.Seq <= sinceSeq { + continue + } all = append(all, *rec) } sort.Slice(all, func(i, j int) bool { return all[i].Seq < all[j].Seq }) @@ -162,6 +164,12 @@ func Read(runDir string, sinceSeq int64, limit int) (api.CommandPage, error) { } if n := len(page.Commands); n > 0 { page.NextSeq = page.Commands[n-1].Seq + for _, rec := range page.Commands { + if rec.Status == api.CommandUnfinished { + page.NextSeq = max(rec.Seq-1, sinceSeq) + break + } + } } return page, nil }