Skip to content

Commit 1af466c

Browse files
committed
feat: consolidate .go-entry + .go-etag into bundle go.toml (RemoteSettings); always check freshness via ETag to source (per-startup file)
- New RemoteSettings + RemoteSettingsStore modeled after global Settings. - go.toml at bundle root now holds 'entry' (for bare refs) and 'etags' map keyed by relative file path or :path. - Remote refs always issue conditional GET (ETag); 304 keeps local, no more 2-week mtime gate for downloads (2w was only for cleanup). - Cleanup now descends into host dirs to prune individual stale ref bundles by their root dir mtime (touched on use). - Updated tests, docs, and resolver logic (GetStartupKey, etc.). - Unrelated local changes left uncommitted.
1 parent c50bdc2 commit 1af466c

7 files changed

Lines changed: 316 additions & 92 deletions

File tree

readme.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,12 @@ The first argument is resolved by first checking if it is a local file (`File.Ex
8484
If not, it falls back to parsing it as a remote ref (`owner/repo[@ref][:path]`).
8585

8686
Downloaded content is cached under the `dotnet/go` directory (same root as local apps)
87-
and participates in the normal up-to-date checks. By default, a remote ref is
88-
considered fresh for **2 weeks**. After that (or when using `--force` / `--go-force`),
89-
it will be re-downloaded.
87+
and participates in the normal up-to-date checks. Remote refs are always revalidated
88+
by sending a conditional request (using ETag when available) to the source. A 304
89+
Not Modified response means the local copy is used as-is.
9090

9191
```console
92-
# Force a fresh download even if within the 2-week window
92+
# Force a fresh download (bypasses ETag conditional check)
9393
dnx go --force kzu/sandbox
9494

9595
# Same using the --go- alias form
@@ -124,9 +124,8 @@ dnx go clean app.cs
124124
dnx go clean --all
125125
```
126126

127-
Caches are also cleaned automatically: at most once every couple of days,
128-
`go#` removes cache directories that haven't been used for a while, in a
129-
detached background process. Apps you run regularly are never affected.
127+
Unused download locations and published binaries are periodically cleaned up
128+
in a detached background process. Apps you run regularly are never affected.
130129

131130
## Performance
132131

src/Tests/EndToEndTests.cs

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -226,12 +226,12 @@ static string CreateTempDir()
226226
}
227227

228228
[Fact]
229-
public void Remote_stale_2week_re_download_refreshes_mtime_immediate_followup_does_not()
229+
public void Remote_ref_always_revalidates_via_etag_root_dir_touched_source_files_not()
230230
{
231231
var scratch = @"C:\Users\kzu\AppData\Local\Temp\grok-goal-4376de8fe197\implementer";
232232
Directory.CreateDirectory(scratch);
233233
var remoteRef = "gist.github.com/kzu/0ac826dc7de666546aaedd38e5965381";
234-
var logPath = Path.Combine(scratch, "stale-download.log");
234+
var logPath = Path.Combine(scratch, "remote-etag.log");
235235

236236
try
237237
{
@@ -243,7 +243,7 @@ public void Remote_stale_2week_re_download_refreshes_mtime_immediate_followup_do
243243
Assert.Equal(0, warm.ExitCode);
244244
Assert.Contains("run.cs", warm.Output);
245245

246-
// Locate the downloaded source cs for this ref (robust search, not brittle FirstOrDefault + loose contains)
246+
// Locate the downloaded source cs and its bundle root dir for this ref
247247
var goRoot = GetTempRoot();
248248
var gistCandidates = Directory.GetDirectories(goRoot, "*0ac826dc7de666546aaedd38e5965381*", SearchOption.AllDirectories)
249249
.OrderByDescending(d => d.Length)
@@ -256,36 +256,54 @@ public void Remote_stale_2week_re_download_refreshes_mtime_immediate_followup_do
256256
Assert.False(string.IsNullOrEmpty(sourceCs), "expected source .cs under download");
257257
var srcInfo = new FileInfo(sourceCs);
258258

259-
// Artificially age it >14 days
260-
var staleTime = DateTime.UtcNow.AddDays(-20);
261-
srcInfo.LastWriteTimeUtc = staleTime;
262-
Assert.True((DateTime.UtcNow - srcInfo.LastWriteTimeUtc).TotalDays > 14);
259+
// The bundle root (unzipped ref dir) is the gistRoot we found (contains the sources for this gist download).
260+
var bundleRoot = gistRoot;
261+
var rootInfo = new DirectoryInfo(bundleRoot);
262+
var rootMtimeBefore = rootInfo.LastWriteTimeUtc;
263263

264-
// Re-invoke: should detect stale, (re)download/touch mtime to recent, succeed with marker
265-
var (exitStale, outStale) = RunGo(remoteRef, "--", "-v:q");
266-
File.WriteAllText(logPath, "STALE-RE-INVOKE:\n" + outStale);
267-
Assert.Equal(0, exitStale);
268-
Assert.Contains("run.cs", outStale);
264+
// Second run (rapid): always performs ETag check; on 304 we touch *only* the root dir,
265+
// individual source files are not touched.
266+
var mtimeBefore = srcInfo.LastWriteTimeUtc;
267+
var (exit2, out2) = RunGo(remoteRef, "--", "-v:q");
268+
File.WriteAllText(logPath, "SECOND-RUN:\n" + out2);
269+
Assert.Equal(0, exit2);
270+
Assert.Contains("run.cs", out2);
269271

270272
srcInfo.Refresh();
271-
var afterStaleMtime = srcInfo.LastWriteTimeUtc;
272-
File.AppendAllText(logPath, $"\nSOURCE-MTIME-AFTER-STALE: {afterStaleMtime:O}\n");
273-
Assert.True((DateTime.UtcNow - afterStaleMtime).TotalMinutes < 5, "source mtime should be refreshed to recent by re-dl");
273+
rootInfo.Refresh();
274+
var mtimeAfter = srcInfo.LastWriteTimeUtc;
275+
var rootMtimeAfter = rootInfo.LastWriteTimeUtc;
276+
277+
File.AppendAllText(logPath, $"\nSOURCE-MTIME-AFTER: {mtimeAfter:O}\nROOT-MTIME-AFTER: {rootMtimeAfter:O}\n");
278+
279+
// Source file mtime must not have been updated by resolver (no more per-file touch on 304 or extract).
280+
var fileDelta = (mtimeAfter - mtimeBefore).TotalSeconds;
281+
Assert.True(fileDelta < 2.0, $"source file mtime must be unchanged on revalidation (delta={fileDelta}s)");
274282

275-
// Immediate follow-up: must NOT update the source mtime (plan requirement)
276-
var mtimeBeforeImmediate = srcInfo.LastWriteTimeUtc;
283+
// Root download dir *must* be touched to mark usage for cleanup.
284+
Assert.True(rootMtimeAfter >= rootMtimeBefore, "bundle root dir mtime should be updated on use");
285+
286+
// Immediate third run: same, no source mtime change.
287+
var mtimeBeforeImm = srcInfo.LastWriteTimeUtc;
288+
var rootBeforeImm = rootInfo.LastWriteTimeUtc;
277289
var (exitImm, outImm) = RunGo(remoteRef, "--", "-v:q");
278-
File.AppendAllText(logPath, "IMMEDIATE-FOLLOWUP:\n" + outImm);
290+
File.AppendAllText(logPath, "IMMEDIATE:\n" + outImm);
279291
Assert.Equal(0, exitImm);
280-
Assert.Contains("run.cs", outImm);
281292

282293
srcInfo.Refresh();
283-
var mtimeAfterImmediate = srcInfo.LastWriteTimeUtc;
284-
File.AppendAllText(logPath, $"SOURCE-MTIME-AFTER-IMMEDIATE: {mtimeAfterImmediate:O}\n");
285-
286-
var deltaSeconds = (mtimeAfterImmediate - mtimeBeforeImmediate).TotalSeconds;
287-
// The immediate run (cache hit, no dl) must not have updated the source mtime via our logic or observable change.
288-
Assert.True(deltaSeconds < 2.0, $"immediate follow-up must not update source mtime (delta={deltaSeconds}s)");
294+
rootInfo.Refresh();
295+
var mtimeAfterImm = srcInfo.LastWriteTimeUtc;
296+
var rootAfterImm = rootInfo.LastWriteTimeUtc;
297+
File.AppendAllText(logPath, $"SOURCE-MTIME-IMM: {mtimeAfterImm:O}\nROOT-MTIME-IMM: {rootAfterImm:O}\n");
298+
299+
Assert.True((mtimeAfterImm - mtimeBeforeImm).TotalSeconds < 2.0, "immediate must not change source file mtime");
300+
Assert.True(rootAfterImm > rootBeforeImm || rootAfterImm >= rootBeforeImm, "root dir touched on immediate use too");
301+
302+
// --force should succeed (forces full fetch, bypassing any conditional).
303+
var (exitForce, outForce) = RunGo("--force", remoteRef, "--", "-v:q");
304+
File.AppendAllText(logPath, "FORCE:\n" + outForce);
305+
Assert.Equal(0, exitForce);
306+
Assert.Contains("run.cs", outForce);
289307
}
290308
finally
291309
{

src/Tests/GoBuildCacheTests.cs

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -135,47 +135,49 @@ public void GetRemoteEntryPointPath_uses_path_or_program_or_persisted_marker()
135135
p = Devlooped.RemoteSourceResolver.GetRemoteEntryPointPath(withPath);
136136
Assert.EndsWith("src/app.cs", p.Replace('\\', '/'));
137137

138-
// simulate persist
138+
// simulate persist via consolidated go.toml
139139
var dir = Devlooped.RemoteSourceResolver.GetRemoteEntryPointPath(remoteNoPath);
140140
var tempDir = Path.GetDirectoryName(dir)!;
141141
Directory.CreateDirectory(tempDir);
142-
var marker = Path.Combine(tempDir, ".go-entry");
143-
File.WriteAllText(marker, "actual.cs");
142+
var settings = new Devlooped.RemoteSettings { Entry = "actual.cs" };
143+
Devlooped.RemoteSettingsStore.Save(settings, tempDir);
144144
p = Devlooped.RemoteSourceResolver.GetRemoteEntryPointPath(remoteNoPath);
145145
Assert.EndsWith("actual.cs", p);
146146

147-
// cleanup marker for hygiene
148-
try { File.Delete(marker); } catch { }
147+
// cleanup for hygiene
148+
try { File.Delete(Devlooped.RemoteSettingsStore.GetFilePath(tempDir)); } catch { }
149149

150-
// NEW: exercise dir-exists + no-marker discovery branch (must prefer program.cs deterministically, write marker)
150+
// NEW: exercise dir-exists + no-entry discovery branch (must prefer program.cs deterministically, write to go.toml)
151151
var uniq2 = Guid.NewGuid().ToString("N")[..8];
152152
var remoteDisc = new Devlooped.RemoteRef("disc" + uniq2, "repo" + uniq2, null, null, null);
153153
var cand = Devlooped.RemoteSourceResolver.GetRemoteEntryPointPath(remoteDisc);
154154
var discDir = Path.GetDirectoryName(cand)!;
155155
Directory.CreateDirectory(discDir);
156-
var m2 = Path.Combine(discDir, ".go-entry");
157-
if (File.Exists(m2)) File.Delete(m2);
156+
var settingsPath = Devlooped.RemoteSettingsStore.GetFilePath(discDir);
157+
if (File.Exists(settingsPath)) File.Delete(settingsPath);
158158

159159
// write program.cs + another to test prefer
160160
File.WriteAllText(Path.Combine(discDir, "other.cs"), "// other");
161161
var prog = Path.Combine(discDir, "program.cs");
162162
File.WriteAllText(prog, "// prog");
163163
var discovered = Devlooped.RemoteSourceResolver.GetRemoteEntryPointPath(remoteDisc);
164164
Assert.Equal(prog, discovered);
165-
Assert.True(File.Exists(m2));
166-
Assert.Equal("program.cs", File.ReadAllText(m2).Trim());
165+
Assert.True(File.Exists(settingsPath));
166+
var s1 = Devlooped.RemoteSettingsStore.Load(discDir);
167+
Assert.Equal("program.cs", s1.Entry);
167168

168169
// case with no program.cs: should pick the (only) remaining .cs
169170
File.Delete(prog);
170171
var other = Path.Combine(discDir, "other.cs");
171172
if (File.Exists(other)) File.Delete(other);
172-
if (File.Exists(m2)) File.Delete(m2);
173+
if (File.Exists(settingsPath)) File.Delete(settingsPath);
173174
File.WriteAllText(Path.Combine(discDir, "only.cs"), "// only");
174175
var onlyDisc = Devlooped.RemoteSourceResolver.GetRemoteEntryPointPath(remoteDisc);
175176
Assert.EndsWith("only.cs", onlyDisc);
176-
Assert.Equal("only.cs", File.ReadAllText(m2).Trim());
177+
var s2 = Devlooped.RemoteSettingsStore.Load(discDir);
178+
Assert.Equal("only.cs", s2.Entry);
177179

178-
try { File.Delete(m2); Directory.Delete(discDir, true); } catch { }
180+
try { File.Delete(settingsPath); Directory.Delete(discDir, true); } catch { }
179181
}
180182

181183
[Fact]

src/go/CleanupManager.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,22 @@ public static void CleanupStaleDirectories(string root, int days)
1818

1919
foreach (var directory in Directory.EnumerateDirectories(root))
2020
{
21+
var name = Path.GetFileName(directory);
22+
if (name.Contains('.'))
23+
{
24+
// Remote host container (e.g. github.com, gist.github.com). Descend to clean
25+
// individual ref root (unzipped download) directories by their mtime.
26+
CleanupRemoteDownloadLocations(directory, cutoff);
27+
// Prune empty host container if all refs under it were removed.
28+
try
29+
{
30+
if (!Directory.EnumerateFileSystemEntries(directory).Any())
31+
Directory.Delete(directory);
32+
}
33+
catch { }
34+
continue;
35+
}
36+
2137
if (Directory.GetLastWriteTimeUtc(directory) >= cutoff)
2238
continue;
2339

@@ -31,4 +47,50 @@ public static void CleanupStaleDirectories(string root, int days)
3147
}
3248
}
3349
}
50+
51+
static void CleanupRemoteDownloadLocations(string hostDir, DateTime cutoff)
52+
{
53+
// Find leaf-ish ref bundle roots: directories containing top-level .cs files or a go.toml
54+
// (consolidated settings for entry + etag). We only consider their own LastWriteTimeUtc
55+
// (touched on use of any file in the bundle).
56+
foreach (var dir in EnumeratePotentialBundleDirs(hostDir))
57+
{
58+
try
59+
{
60+
if (!Directory.Exists(dir))
61+
continue;
62+
if (Directory.GetLastWriteTimeUtc(dir) >= cutoff)
63+
continue;
64+
65+
// Confirm it looks like one of our download bundles
66+
if (Directory.EnumerateFiles(dir, "*.cs", SearchOption.TopDirectoryOnly).Any() ||
67+
File.Exists(Path.Combine(dir, "go.toml")))
68+
{
69+
Directory.Delete(dir, recursive: true);
70+
}
71+
}
72+
catch
73+
{
74+
// Best-effort per location.
75+
}
76+
}
77+
}
78+
79+
static IEnumerable<string> EnumeratePotentialBundleDirs(string start)
80+
{
81+
var stack = new Stack<string>();
82+
stack.Push(start);
83+
while (stack.Count > 0)
84+
{
85+
var current = stack.Pop();
86+
// Yield current if it may be a bundle root (we'll filter by contents at call site).
87+
yield return current;
88+
try
89+
{
90+
foreach (var sub in Directory.EnumerateDirectories(current))
91+
stack.Push(sub);
92+
}
93+
catch { }
94+
}
95+
}
3496
}

src/go/RemoteSettings.cs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using System.Text;
2+
using System.Text.Json.Serialization;
3+
using Tomlyn;
4+
using Tomlyn.Serialization;
5+
6+
namespace Devlooped;
7+
8+
public class RemoteSettings
9+
{
10+
public string? Entry { get; set; }
11+
12+
/// <summary>
13+
/// ETags keyed by the relative file path within the bundle (e.g. "program.cs" or "src/hello.cs")
14+
/// or equivalently the path portion from a remote ref (owner/repo:path).
15+
/// The go.toml at the bundle root holds ETags for all such refs/entries within the same bundle.
16+
/// </summary>
17+
public Dictionary<string, string>? ETags { get; set; }
18+
}
19+
20+
public static class RemoteSettingsStore
21+
{
22+
public static string GetFilePath(string directory)
23+
=> Path.Combine(directory, "go.toml");
24+
25+
public static RemoteSettings Load(string? path = null)
26+
{
27+
if (string.IsNullOrEmpty(path))
28+
return new RemoteSettings();
29+
30+
path = ResolveTomlPath(path);
31+
32+
if (!File.Exists(path))
33+
return new RemoteSettings();
34+
35+
var text = File.ReadAllText(path);
36+
return TomlSerializer.Deserialize(text, TomlContext.Default.RemoteSettings) ?? new RemoteSettings();
37+
}
38+
39+
public static void Save(RemoteSettings settings, string? path = null)
40+
{
41+
if (string.IsNullOrEmpty(path))
42+
path = "go.toml";
43+
44+
path = ResolveTomlPath(path);
45+
46+
var dir = Path.GetDirectoryName(path);
47+
if (!string.IsNullOrEmpty(dir))
48+
Directory.CreateUserDirectory(dir);
49+
50+
var text = TomlSerializer.Serialize(settings, TomlContext.Default.RemoteSettings);
51+
File.WriteAllText(path, text, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
52+
}
53+
54+
public static string? GetETag(RemoteSettings settings, string key)
55+
{
56+
if (settings.ETags == null) return null;
57+
if (settings.ETags.TryGetValue(key, out var value)) return value;
58+
// Case-insensitive fallback for robustness across platforms/casing in refs
59+
foreach (var kv in settings.ETags)
60+
if (string.Equals(kv.Key, key, StringComparison.OrdinalIgnoreCase))
61+
return kv.Value;
62+
return null;
63+
}
64+
65+
public static void SetETag(RemoteSettings settings, string key, string etag)
66+
{
67+
settings.ETags ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
68+
settings.ETags[key] = etag;
69+
}
70+
71+
static string ResolveTomlPath(string path)
72+
{
73+
// Treat as directory if it exists as a directory or doesn't look like a .toml file path
74+
if (Directory.Exists(path) || !path.EndsWith(".toml", StringComparison.OrdinalIgnoreCase))
75+
return Path.Combine(path, "go.toml");
76+
return path;
77+
}
78+
}
79+
80+
[TomlSerializable(typeof(RemoteSettings))]
81+
partial class TomlContext : TomlSerializerContext { }

0 commit comments

Comments
 (0)