From f7b06ff229de43d9740954bd63f6ff86fc43a0d8 Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Sun, 4 Oct 2026 15:25:25 -0700 Subject: [PATCH] fix(mount): bound GitHub tar seed bootstrap --- internal/mountsync/symlink_mode_test.go | 2 +- internal/mountsync/syncer.go | 102 +++++++++++-- internal/mountsync/syncer_test.go | 182 +++++++++++++++++++++--- 3 files changed, 258 insertions(+), 28 deletions(-) diff --git a/internal/mountsync/symlink_mode_test.go b/internal/mountsync/symlink_mode_test.go index d6ec2896..12fff39c 100644 --- a/internal/mountsync/symlink_mode_test.go +++ b/internal/mountsync/symlink_mode_test.go @@ -286,7 +286,7 @@ func TestCompleteTarSeedRejectsUnsupportedEntryType(t *testing.T) { if err := tw.Close(); err != nil { t.Fatalf("close tar: %v", err) } - _, err = syncer.applyGithubWorkingTreeTarSeedStrict(GithubWorkingTreeTar{Body: io.NopCloser(bytes.NewReader(buf.Bytes()))}, nil, nil, bootstrapProgress{}, true) + _, err = syncer.applyGithubWorkingTreeTarSeedStrict(context.Background(), GithubWorkingTreeTar{Body: io.NopCloser(bytes.NewReader(buf.Bytes()))}, nil, nil, bootstrapProgress{}, true) if err == nil || !strings.Contains(err.Error(), "unsupported entry type") { t.Fatalf("unsupported complete-v1 tar error = %v", err) } diff --git a/internal/mountsync/syncer.go b/internal/mountsync/syncer.go index b7436480..abbfbb78 100644 --- a/internal/mountsync/syncer.go +++ b/internal/mountsync/syncer.go @@ -180,6 +180,14 @@ const ( // #1499/#1516 non-convergence loop). Must stay strictly under // defaultBootstrapIdleTimeout. defaultExportTimeout = 45 * time.Second + // defaultGithubTarSeedTimeout bounds the retained GitHub archive seed with + // its own deadline. The seed performs several reads and then one atomic tar + // download without reporting applied-file progress; if it consumes the + // outer bootstrap idle window, the resumable tree fallback never gets a + // live parent context in the same cycle. Agent37 raises the outer idle + // window to 240s, leaving two minutes for the fallback to establish and + // persist its first checkpoint when this 120s seed deadline fires. + defaultGithubTarSeedTimeout = 120 * time.Second // defaultOutboxFlushTimeout bounds a durable writeback/outbox flush with // its OWN deadline derived from rootCtx (see outboxContext), independent of // the tiny per-cycle RELAYFILE_MOUNT_TIMEOUT. A small outbound write (e.g. a @@ -1671,6 +1679,12 @@ type SyncerOptions struct { // RELAYFILE_EXPORT_TIMEOUT, default 45s; values >= bootstrapIdleTimeout are // clamped below it so the fall-through always fires before the watchdog. ExportTimeout time.Duration + // GithubTarSeedTimeout bounds the retained GitHub archive seed with its OWN + // deadline derived from the bootstrap ctx. When it expires while the parent + // remains live, the syncer falls through to pullRemoteFullTree in the same + // cycle. 0 uses RELAYFILE_GITHUB_TAR_SEED_TIMEOUT, default 120s; it is + // clamped below the active bootstrap idle/hard-cap window. + GithubTarSeedTimeout time.Duration // OutboxFlushTimeout bounds a durable writeback/outbox flush with its OWN // deadline derived from RootCtx (see outboxContext), so the tiny per-cycle // RELAYFILE_MOUNT_TIMEOUT that bounds a mirror cycle cannot starve an @@ -1876,6 +1890,7 @@ type Syncer struct { bootstrapMaxFilesPerCycle int cursorTimeout time.Duration exportTimeout time.Duration + githubTarSeedTimeout time.Duration outboxFlushTimeout time.Duration bootstrapTimeout time.Duration bootstrapIdleTimeout time.Duration @@ -2581,6 +2596,10 @@ func NewSyncer(client RemoteClient, opts SyncerOptions) (*Syncer, error) { if exportTimeout <= 0 { exportTimeout = defaultExportTimeout } + githubTarSeedTimeout := resolveDurationEnv(opts.GithubTarSeedTimeout, "RELAYFILE_GITHUB_TAR_SEED_TIMEOUT", defaultGithubTarSeedTimeout, opts.Logger) + if githubTarSeedTimeout <= 0 { + githubTarSeedTimeout = defaultGithubTarSeedTimeout + } outboxFlushTimeout := resolveDurationEnv(opts.OutboxFlushTimeout, "RELAYFILE_OUTBOX_TIMEOUT", defaultOutboxFlushTimeout, opts.Logger) if outboxFlushTimeout <= 0 { outboxFlushTimeout = defaultOutboxFlushTimeout @@ -2593,18 +2612,24 @@ func NewSyncer(client RemoteClient, opts SyncerOptions) (*Syncer, error) { // RELAYFILE_BOOTSTRAP_TIMEOUT would otherwise cancel the parent ctx before // the export sub-deadline, defeating the same-cycle tree fall-through and // re-creating the #1499/#1516 stall loop). - maxExportTimeout := bootstrapIdleTimeout * 3 / 4 + maxBootstrapSubdeadline := bootstrapIdleTimeout * 3 / 4 if bootstrapTimeout > 0 { hardCapMax := bootstrapTimeout * 3 / 4 - if maxExportTimeout <= 0 || hardCapMax < maxExportTimeout { - maxExportTimeout = hardCapMax + if maxBootstrapSubdeadline <= 0 || hardCapMax < maxBootstrapSubdeadline { + maxBootstrapSubdeadline = hardCapMax } } - if maxExportTimeout > 0 && exportTimeout > maxExportTimeout { + if maxBootstrapSubdeadline > 0 && exportTimeout > maxBootstrapSubdeadline { if opts.Logger != nil { - opts.Logger.Printf("clamping exportTimeout from %s to %s (must stay strictly under the active bootstrap window — no-progress watchdog %s, hard cap %s — so the export yields to the resumable tree pull before the bootstrap ctx is canceled)", exportTimeout, maxExportTimeout, bootstrapIdleTimeout, bootstrapTimeout) + opts.Logger.Printf("clamping exportTimeout from %s to %s (must stay strictly under the active bootstrap window — no-progress watchdog %s, hard cap %s — so the export yields to the resumable tree pull before the bootstrap ctx is canceled)", exportTimeout, maxBootstrapSubdeadline, bootstrapIdleTimeout, bootstrapTimeout) } - exportTimeout = maxExportTimeout + exportTimeout = maxBootstrapSubdeadline + } + if maxBootstrapSubdeadline > 0 && githubTarSeedTimeout > maxBootstrapSubdeadline { + if opts.Logger != nil { + opts.Logger.Printf("clamping githubTarSeedTimeout from %s to %s (must stay strictly under the active bootstrap window — no-progress watchdog %s, hard cap %s — so the seed yields to the resumable tree pull before the bootstrap ctx is canceled)", githubTarSeedTimeout, maxBootstrapSubdeadline, bootstrapIdleTimeout, bootstrapTimeout) + } + githubTarSeedTimeout = maxBootstrapSubdeadline } readNotReadyTTL := resolveDurationEnv(opts.IncrementalReadNotReadyTTL, "RELAYFILE_INCREMENTAL_READ_NOT_READY_TTL", defaultIncrementalReadNotReadyTTL, opts.Logger) if readNotReadyTTL <= 0 { @@ -2718,6 +2743,7 @@ func NewSyncer(client RemoteClient, opts SyncerOptions) (*Syncer, error) { bootstrapMaxFilesPerCycle: bootstrapMaxFilesPerCycle, cursorTimeout: cursorTimeout, exportTimeout: exportTimeout, + githubTarSeedTimeout: githubTarSeedTimeout, outboxFlushTimeout: outboxFlushTimeout, bootstrapTimeout: bootstrapTimeout, bootstrapIdleTimeout: bootstrapIdleTimeout, @@ -6629,8 +6655,21 @@ func (s *Syncer) pullRemoteFull(ctx context.Context, conflicted map[string]struc }() } var seedErr error + seedTimedOut := false if client, ok := s.client.(githubWorkingTreeTarClient); ok { - used, err := s.pullRemoteFullGithubTarSeed(ctx, client, conflicted, prog) + seedCtx := ctx + var cancelSeed context.CancelFunc + if s.githubTarSeedTimeout > 0 { + seedCtx, cancelSeed = context.WithTimeout(ctx, s.githubTarSeedTimeout) + } + used, err := s.pullRemoteFullGithubTarSeed(seedCtx, client, conflicted, prog) + seedTimedOut = err != nil && ctx.Err() == nil && seedCtx.Err() != nil + if cancelSeed != nil { + cancelSeed() + } + if seedTimedOut { + s.logf("github tar seed did not complete within %s; falling back to resumable tree pull", s.githubTarSeedTimeout) + } if used { if err == nil { return nil @@ -6641,7 +6680,9 @@ func (s *Syncer) pullRemoteFull(ctx context.Context, conflicted map[string]struc seedErr = err } } - if !s.state.BootstrapComplete { + if seedTimedOut { + s.logf("skipping atomic export after github tar seed deadline; using bounded resumable tree pull") + } else if !s.state.BootstrapComplete { s.logf("skipping atomic export for initial bootstrap; using bounded resumable tree pull") } else if client, ok := s.client.(exportSnapshotClient); ok { used, err := s.pullRemoteFullExport(ctx, client, conflicted, prog) @@ -6775,10 +6816,13 @@ func (s *Syncer) pullRemoteFullGithubTarSeed(ctx context.Context, client githubW }() s.recordCloudSuccess() - remotePaths, err := s.applyGithubWorkingTreeTarSeedStrict(tarBody, tree, conflicted, prog, strings.EqualFold(strings.TrimSpace(manifest.SourceProfile), "complete-v1")) + remotePaths, err := s.applyGithubWorkingTreeTarSeedStrict(ctx, tarBody, tree, conflicted, prog, strings.EqualFold(strings.TrimSpace(manifest.SourceProfile), "complete-v1")) if err != nil { return true, err } + if err := ctx.Err(); err != nil { + return true, err + } if len(remotePaths) != len(tree) { return true, fmt.Errorf("github tar seed verification failed: tar contained %d verified files, tree expected %d", len(remotePaths), len(tree)) } @@ -7195,7 +7239,7 @@ func (s *Syncer) githubWorkingTreeSnapshot(ctx context.Context, prog bootstrapPr } func (s *Syncer) applyGithubWorkingTreeTarSeed(tarBody GithubWorkingTreeTar, tree map[string]githubTreeFile, conflicted map[string]struct{}, prog bootstrapProgress) (map[string]struct{}, error) { - return s.applyGithubWorkingTreeTarSeedStrict(tarBody, tree, conflicted, prog, false) + return s.applyGithubWorkingTreeTarSeedStrict(context.Background(), tarBody, tree, conflicted, prog, false) } func stripTarPathComponents(name string, count int) (string, error) { @@ -7214,7 +7258,7 @@ func stripTarPathComponents(name string, count int) (string, error) { return strings.Join(parts[count:], "/"), nil } -func (s *Syncer) applyGithubWorkingTreeTarSeedStrict(tarBody GithubWorkingTreeTar, tree map[string]githubTreeFile, conflicted map[string]struct{}, prog bootstrapProgress, strictComplete bool) (map[string]struct{}, error) { +func (s *Syncer) applyGithubWorkingTreeTarSeedStrict(ctx context.Context, tarBody GithubWorkingTreeTar, tree map[string]githubTreeFile, conflicted map[string]struct{}, prog bootstrapProgress, strictComplete bool) (map[string]struct{}, error) { const maxAbandonedStagingEntries = 100000 type localPathObservation struct { snapshot localSnapshot @@ -7232,6 +7276,9 @@ func (s *Syncer) applyGithubWorkingTreeTarSeedStrict(tarBody GithubWorkingTreeTa } sort.Strings(baselinePaths) for _, rel := range baselinePaths { + if err := ctx.Err(); err != nil { + return nil, err + } localPath, err := safeLocalPath(s.localRoot, rel) if err != nil { return nil, err @@ -7240,6 +7287,9 @@ func (s *Syncer) applyGithubWorkingTreeTarSeedStrict(tarBody GithubWorkingTreeTa return nil, err } snapshot, readErr := s.readLocalSnapshot(localPath, false) + if err := ctx.Err(); err != nil { + return nil, err + } observation := localPathObservation{snapshot: snapshot, existed: readErr == nil, readable: readErr == nil || errors.Is(readErr, os.ErrNotExist), readErr: readErr} localBaselines[rel] = observation // A warm mount can hash thousands of local files here. Yield between @@ -7260,6 +7310,9 @@ func (s *Syncer) applyGithubWorkingTreeTarSeedStrict(tarBody GithubWorkingTreeTa // Cleanup first verifies ownership and a fixed entry budget without following // symlinks; subsequent secure writes also open every directory component // with O_NOFOLLOW. + if err := ctx.Err(); err != nil { + return nil, err + } if err := removeOwnedStagingTreeBounded(stagingRoot, maxAbandonedStagingEntries); err != nil { return nil, fmt.Errorf("remove abandoned github tar staging directory: %w", err) } @@ -7300,11 +7353,17 @@ func (s *Syncer) applyGithubWorkingTreeTarSeedStrict(tarBody GithubWorkingTreeTa remotePaths := map[string]struct{}{} seen := map[string]struct{}{} for { + if err := ctx.Err(); err != nil { + return nil, err + } var header *tar.Header var err error s.runFullPullIO(func() { header, err = tr.Next() }) + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } if errors.Is(err, io.EOF) { break } @@ -7366,6 +7425,9 @@ func (s *Syncer) applyGithubWorkingTreeTarSeedStrict(tarBody GithubWorkingTreeTa s.runFullPullIO(func() { data, err = io.ReadAll(io.LimitReader(tr, header.Size+1)) }) + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } if err != nil { return nil, err } @@ -7443,6 +7505,9 @@ func (s *Syncer) applyGithubWorkingTreeTarSeedStrict(tarBody GithubWorkingTreeTa } stagedPath := "" if shouldWrite { + if err := ctx.Err(); err != nil { + return nil, err + } stagedPath, err = safeLocalPath(stagingRoot, rel) if err != nil { return nil, err @@ -7462,6 +7527,9 @@ func (s *Syncer) applyGithubWorkingTreeTarSeedStrict(tarBody GithubWorkingTreeTa if writeErr != nil { return nil, writeErr } + if err := ctx.Err(); err != nil { + return nil, err + } } contentType := detectContentType(localPath) pending = append(pending, pendingPublish{ @@ -7492,6 +7560,9 @@ func (s *Syncer) applyGithubWorkingTreeTarSeedStrict(tarBody GithubWorkingTreeTa prog.touch() } for rel, meta := range tree { + if err := ctx.Err(); err != nil { + return nil, err + } if _, ok := remotePaths[meta.RemotePath]; !ok { return nil, fmt.Errorf("github tar seed missing tree file %s", rel) } @@ -7501,6 +7572,9 @@ func (s *Syncer) applyGithubWorkingTreeTarSeedStrict(tarBody GithubWorkingTreeTa // the existing secure atomic writer; a crash leaves only an ignored staging // directory outside the mount, and the next bootstrap can restart cleanly. for _, entry := range pending { + if err := ctx.Err(); err != nil { + return nil, err + } // Give watcher/outbox work a turn between each disk publication, then // fence on both accepted writes and pending/failed local edits. The // archive entry was staged against observedState; any newer tracked @@ -7525,6 +7599,9 @@ func (s *Syncer) applyGithubWorkingTreeTarSeedStrict(tarBody GithubWorkingTreeTa continue } localNow, localReadErr := s.readLocalSnapshot(entry.localPath, false) + if err := ctx.Err(); err != nil { + return nil, err + } localExistsNow := localReadErr == nil if localReadErr != nil && !errors.Is(localReadErr, os.ErrNotExist) { // Treat an unreadable final snapshot as a local concurrent change. @@ -7555,6 +7632,9 @@ func (s *Syncer) applyGithubWorkingTreeTarSeedStrict(tarBody GithubWorkingTreeTa s.clearSkippedMaterialization(entry.remotePath) prog.touch() } + if err := ctx.Err(); err != nil { + return nil, err + } return remotePaths, nil } diff --git a/internal/mountsync/syncer_test.go b/internal/mountsync/syncer_test.go index 2ccb1207..b0d30e5b 100644 --- a/internal/mountsync/syncer_test.go +++ b/internal/mountsync/syncer_test.go @@ -3398,6 +3398,150 @@ func TestPullRemoteFullGithubWorkingTreeTarSeedFallsBackToTree(t *testing.T) { } } +func TestGithubWorkingTreeTarSeedDeadlineLeavesBootstrapFallbackBudget(t *testing.T) { + t.Run("agent37 idle window keeps the 120 second seed deadline", func(t *testing.T) { + t.Setenv("RELAYFILE_BOOTSTRAP_IDLE_TIMEOUT", "240s") + t.Setenv("RELAYFILE_BOOTSTRAP_TIMEOUT", "") + t.Setenv("RELAYFILE_GITHUB_TAR_SEED_TIMEOUT", "") + syncer, err := NewSyncer(&fakeClient{}, SyncerOptions{ + WorkspaceID: "ws_tar_seed_agent37_budget", + RemoteRoot: "/github/repos/AgentWorkforce/cloud/contents", + LocalRoot: t.TempDir(), + }) + if err != nil { + t.Fatalf("NewSyncer failed: %v", err) + } + if got, want := syncer.githubTarSeedTimeout, 120*time.Second; got != want { + t.Fatalf("unexpected github tar seed timeout: got %s, want %s", got, want) + } + }) + + t.Run("shorter outer window clamps the seed below its parent", func(t *testing.T) { + t.Setenv("RELAYFILE_BOOTSTRAP_IDLE_TIMEOUT", "80s") + t.Setenv("RELAYFILE_BOOTSTRAP_TIMEOUT", "") + t.Setenv("RELAYFILE_GITHUB_TAR_SEED_TIMEOUT", "") + syncer, err := NewSyncer(&fakeClient{}, SyncerOptions{ + WorkspaceID: "ws_tar_seed_clamped_budget", + RemoteRoot: "/github/repos/AgentWorkforce/cloud/contents", + LocalRoot: t.TempDir(), + GithubTarSeedTimeout: 2 * time.Minute, + }) + if err != nil { + t.Fatalf("NewSyncer failed: %v", err) + } + if got, want := syncer.githubTarSeedTimeout, 60*time.Second; got != want { + t.Fatalf("unexpected clamped github tar seed timeout: got %s, want %s", got, want) + } + }) +} + +func TestPullRemoteFullGithubWorkingTreeTarSeedDeadlineFallsBackToTreeInSameCycle(t *testing.T) { + localDir := t.TempDir() + contentsRoot := "/github/repos/AgentWorkforce/cloud/contents" + headSHA := "head123" + readme := []byte("# Cloud\n") + readmeRemote := contentsRoot + "/README.md@" + headSHA + ".json" + sentinelPath := "/github/repos/AgentWorkforce/cloud/.relayfile/clone.json" + client := &fakeExportClient{ + fakeClient: &fakeClient{ + files: map[string]RemoteFile{ + sentinelPath: { + Path: sentinelPath, + Revision: "rev_1", + ContentType: "application/json", + Content: `{"headSha":"` + headSHA + `","defaultBranch":"main","sourceProfile":"complete-v1","filesExpected":1}`, + }, + readmeRemote: { + Path: readmeRemote, + Revision: "rev_2", + ContentType: "application/json", + Content: string(readme), + ContentHash: hashBytes(readme), + }, + }, + events: []FilesystemEvent{ + {EventID: "evt_1", Type: "file.created", Path: readmeRemote, Revision: "rev_2", ContentHash: hashBytes(readme)}, + {EventID: "evt_2", Type: "file.updated", Path: sentinelPath, Revision: "rev_1"}, + }, + }, + tarBlockUntilCancel: true, + exportBlockUntilCancel: true, + } + syncer, err := NewSyncer(client, SyncerOptions{ + WorkspaceID: "ws_tar_seed_deadline_fallback", + RemoteRoot: contentsRoot, + LocalRoot: localDir, + StateFile: filepath.Join(localDir, ".relayfile-mount-state.json"), + WebSocket: boolPtr(false), + FullPullEvery: -1, + GithubTarSeedTimeout: 50 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewSyncer failed: %v", err) + } + // A completed mount would normally try the atomic JSON export after a tar + // seed failure. A tar deadline must skip that second atomic request so the + // resumable tree receives the remaining parent bootstrap window. + syncer.state.BootstrapComplete = true + + started := time.Now() + if err := syncer.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile should fall back to tree after tar seed deadline: %v", err) + } + if elapsed := time.Since(started); elapsed > 2*time.Second { + t.Fatalf("tar seed deadline did not yield promptly to tree fallback: %s", elapsed) + } + if client.tarCalls != 1 { + t.Fatalf("expected github tar export once, got %d", client.tarCalls) + } + if client.exportCalls != 0 { + t.Fatalf("atomic export ran after tar seed deadline: %d calls", client.exportCalls) + } + if client.listTreeCalls < 2 { + t.Fatalf("expected verification snapshot plus same-cycle tree fallback, got %d list calls", client.listTreeCalls) + } + if client.readFileCalls == 0 { + t.Fatal("expected same-cycle tree fallback to read the remote file") + } + assertLocalFileContent(t, filepath.Join(localDir, "README.md"), string(readme)) + if !syncer.state.BootstrapComplete { + t.Fatal("same-cycle tree fallback did not complete bootstrap") + } +} + +func TestApplyGithubWorkingTreeTarSeedHonorsCanceledContext(t *testing.T) { + localDir := t.TempDir() + syncer, err := NewSyncer(&fakeClient{}, SyncerOptions{ + WorkspaceID: "ws_tar_seed_canceled_apply", + RemoteRoot: "/github/repos/AgentWorkforce/cloud/contents", + LocalRoot: localDir, + }) + if err != nil { + t.Fatalf("NewSyncer failed: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = syncer.applyGithubWorkingTreeTarSeedStrict( + ctx, + GithubWorkingTreeTar{Body: io.NopCloser(strings.NewReader(""))}, + map[string]githubTreeFile{ + "README.md": { + RemotePath: "/github/repos/AgentWorkforce/cloud/contents/README.md@head.json", + Type: remoteTypeFile, + }, + }, + nil, + bootstrapProgress{}, + true, + ) + if !errors.Is(err, context.Canceled) { + t.Fatalf("tar seed apply error = %v, want context canceled", err) + } + if _, statErr := os.Stat(filepath.Join(localDir, "README.md")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("canceled tar seed published README.md: %v", statErr) + } +} + type unsupportedGithubTreeClient struct{ *fakeClient } func (c *unsupportedGithubTreeClient) ListTree(context.Context, string, string, int, string) (TreeResponse, error) { @@ -3942,7 +4086,7 @@ func TestGithubWorkingTreeSourceArchiveStripsTopLevelDirectory(t *testing.T) { t.Fatalf("close tar: %v", err) } - remotePaths, err := syncer.applyGithubWorkingTreeTarSeedStrict(GithubWorkingTreeTar{ + remotePaths, err := syncer.applyGithubWorkingTreeTarSeedStrict(context.Background(), GithubWorkingTreeTar{ Body: io.NopCloser(bytes.NewReader(buf.Bytes())), ContentType: "application/x-tar", StripComponents: 1, }, map[string]githubTreeFile{ "README.md": {RemotePath: remotePath, Revision: "rev_1", ContentHash: hashBytes(body), Type: remoteTypeFile, Mode: 0}, @@ -3995,7 +4139,7 @@ func TestGithubWorkingTreeTarSeedStagesBeforePublishing(t *testing.T) { t.Fatalf("close tar: %v", err) } - _, err = syncer.applyGithubWorkingTreeTarSeedStrict(GithubWorkingTreeTar{ + _, err = syncer.applyGithubWorkingTreeTarSeedStrict(context.Background(), GithubWorkingTreeTar{ Body: io.NopCloser(bytes.NewReader(buf.Bytes())), ContentType: "application/x-tar", StripComponents: 1, }, map[string]githubTreeFile{ "first.txt": {RemotePath: firstRemote, Revision: "rev_1", ContentHash: hashBytes(firstBody), Type: remoteTypeFile, Mode: 0o644}, @@ -4054,7 +4198,7 @@ func TestGithubWorkingTreeTarSeedReclaimsAbandonedPerMountStage(t *testing.T) { if err := tw.Close(); err != nil { t.Fatalf("close tar: %v", err) } - _, err = syncer.applyGithubWorkingTreeTarSeedStrict(GithubWorkingTreeTar{ + _, err = syncer.applyGithubWorkingTreeTarSeedStrict(context.Background(), GithubWorkingTreeTar{ Body: io.NopCloser(bytes.NewReader(buf.Bytes())), ContentType: "application/x-tar", }, map[string]githubTreeFile{ "fresh.txt": {RemotePath: remotePath, Revision: "rev_1", ContentHash: hashBytes(body), Type: remoteTypeFile, Mode: 0o644}, @@ -4122,7 +4266,7 @@ func TestGithubWorkingTreeTarSeedPreservesEditThatBecomesDirtyAfterStaging(t *te go func() { syncer.mu.Lock() syncer.fullPullActive = true - _, applyErr := syncer.applyGithubWorkingTreeTarSeedStrict(GithubWorkingTreeTar{ + _, applyErr := syncer.applyGithubWorkingTreeTarSeedStrict(context.Background(), GithubWorkingTreeTar{ Body: io.NopCloser(body), ContentType: "application/x-tar", }, tree, nil, bootstrapProgress{}, true) syncer.fullPullActive = false @@ -4212,7 +4356,7 @@ func TestGithubWorkingTreeTarSeedPreservesEditBeforeWatcherMarksDirty(t *testing go func() { syncer.mu.Lock() syncer.fullPullActive = true - _, applyErr := syncer.applyGithubWorkingTreeTarSeedStrict(GithubWorkingTreeTar{ + _, applyErr := syncer.applyGithubWorkingTreeTarSeedStrict(context.Background(), GithubWorkingTreeTar{ Body: io.NopCloser(body), ContentType: "application/x-tar", }, tree, nil, bootstrapProgress{}, true) syncer.fullPullActive = false @@ -4282,7 +4426,7 @@ func TestGithubWorkingTreeTarSeedSkipsUnreadableChangedPathAndPublishesOthers(t if err := tw.Close(); err != nil { t.Fatalf("close tar: %v", err) } - _, err = syncer.applyGithubWorkingTreeTarSeedStrict(GithubWorkingTreeTar{ + _, err = syncer.applyGithubWorkingTreeTarSeedStrict(context.Background(), GithubWorkingTreeTar{ Body: io.NopCloser(bytes.NewReader(buf.Bytes())), ContentType: "application/x-tar", }, map[string]githubTreeFile{ "first.txt": { @@ -4348,7 +4492,7 @@ func TestGithubWorkingTreeTarSeedPreScanYieldsAndTouchesProgress(t *testing.T) { syncer.fullPullActive = true close(lockHeld) <-startApply - _, applyErr := syncer.applyGithubWorkingTreeTarSeedStrict(GithubWorkingTreeTar{ + _, applyErr := syncer.applyGithubWorkingTreeTarSeedStrict(context.Background(), GithubWorkingTreeTar{ Body: io.NopCloser(reader), ContentType: "application/x-tar", }, map[string]githubTreeFile{ "file.txt": { @@ -4433,7 +4577,7 @@ func TestGithubWorkingTreeSourceArchiveMaterializesRealisticTree(t *testing.T) { t.Fatalf("close gzip: %v", err) } - remotePaths, err := syncer.applyGithubWorkingTreeTarSeedStrict(GithubWorkingTreeTar{ + remotePaths, err := syncer.applyGithubWorkingTreeTarSeedStrict(context.Background(), GithubWorkingTreeTar{ Body: io.NopCloser(bytes.NewReader(buf.Bytes())), ContentType: "application/gzip", StripComponents: 1, }, tree, nil, bootstrapProgress{}, true) if err != nil { @@ -10786,13 +10930,16 @@ type deleteCall struct { type fakeExportClient struct { *fakeClient - exportCalls int - tarCalls int - lastTarSeed GithubWorkingTreeSeedRequest - tarFiles map[string][]byte - tarErr error - readFileCalls int - exportErr error + exportCalls int + tarCalls int + lastTarSeed GithubWorkingTreeSeedRequest + tarFiles map[string][]byte + tarErr error + // tarBlockUntilCancel simulates a retained archive whose headers/body do + // not arrive before the seed sub-deadline. + tarBlockUntilCancel bool + readFileCalls int + exportErr error // exportBlockUntilCancel makes ExportFiles block until its ctx is // cancelled, simulating a slow atomic export that exceeds the export // sub-deadline (or the bootstrap watchdog). @@ -10974,10 +11121,13 @@ func (c *fakeExportClient) ReadFile(ctx context.Context, workspaceID, path strin } func (c *fakeExportClient) ExportGithubWorkingTreeTar(ctx context.Context, workspaceID string, seed GithubWorkingTreeSeedRequest) (GithubWorkingTreeTar, error) { - _ = ctx _ = workspaceID c.tarCalls++ c.lastTarSeed = seed + if c.tarBlockUntilCancel { + <-ctx.Done() + return GithubWorkingTreeTar{}, ctx.Err() + } if c.tarErr != nil { return GithubWorkingTreeTar{}, c.tarErr }