66eb40a24894

close the offer/strip race: offer checks for strips again after uploading and, before head moves, takes back the records and stripped objects it put (3.1.3)

time
2026-09-28 12:30 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
offer strip race
parent
f03dc4f4c998
5 files VERSION+1 −1src/exchange/exchange.v+38 −7src/exchange/exchange_test.v+77 −0src/exchange/strip.v+11 −6v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.1.21+3.1.3

view file

src/exchange/exchange.v+38 −7
@@ -107,26 +107,33 @@107107 	remote_head := backend.get(head_key(name))!108108 	// Records offered by a machine that has not applied a strip would put the109109 	// stripped path back on the remote, where nothing would rewrite it again.110-	// ponytail: an offer racing a strip on another machine inside the same few111-	// seconds can still slip through; closing that needs a remote lock.112-	if unapplied_strip(store, mut backend, name)! {113-		return error('the remote has a strip this repo has not applied — run koh steal')110+	if unapplied_strips(store, mut backend, name)!.len > 0 {111+		return error(strip_not_applied)114112 	}113+	mut uploaded := Uploaded{}115114 	if remote_head.found {116115 		theirs := remote_head.body.bytestr().trim_space()117116 		newer := saves_after(store, local.id, theirs) or {118117 			return error('remote has new saves — run koh steal')119118 		}120119 		for save in newer {121-			upload_save(store, mut backend, name, save, mut report)!120+			upload_save(store, mut backend, name, save, mut report, mut uploaded)!122121 		}123122 	} else {124123 		oldest_first := history.line(store)!125124 		for index := oldest_first.len - 1; index >= 0; index-- {126-			upload_save(store, mut backend, name, oldest_first[index], mut report)!125+			upload_save(store, mut backend, name, oldest_first[index], mut report, mut uploaded)!127126 		}128127 	}129128 	upload_events(store, mut backend, name, mut report)!129+	// A strip sends its event before it lists the saves to rewrite, so one130+	// missed here lists them after these records landed and rewrites them. One131+	// seen here may have rewritten before they landed: take back what it reaches.132+	late := unapplied_strips(store, mut backend, name)!133+	if late.len > 0 {134+		take_back(store, mut backend, name, uploaded, late)!135+		return error(strip_not_applied)136+	}130137 	if !backend.put_if_match(head_key(name), local.id.bytes(), remote_head.tag)! {131138 		return error('remote has new saves — run koh steal')132139 	}@@ -152,7 +159,29 @@152159 	return newer153160 }154161 155-fn upload_save(store &storage.Store, mut backend remote.Backend, repo string, save history.Save, mut report OfferReport) ! {162+const strip_not_applied = 'the remote has a strip this repo has not applied — run koh steal'163+164+// Uploaded is what one offer put on the remote, so a late strip can take it back.165+struct Uploaded {166+mut:167+	saves   []string168+	objects map[string]bool169+}170+171+// take_back deletes the records an offer put, which head never reached, and172+// every object it put that a strip reaches in one of them.173+fn take_back(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded, strips []events.Event) ! {174+	for id in uploaded.saves {175+		for file in history.files_of(store, id)! {176+			if file.hash in uploaded.objects && strips.any(reaches(it, id) && covers(it.path, file.path)) {177+				backend.delete(object_key(repo, file.hash))!178+			}179+		}180+		backend.delete(save_key(repo, id))!181+	}182+}183+184+fn upload_save(store &storage.Store, mut backend remote.Backend, repo string, save history.Save, mut report OfferReport, mut uploaded Uploaded) ! {156185 	files := history.files_of(store, save.id)!157186 	// Saves go up oldest first and head moves only once all of them landed, so158187 	// the parent and its objects are on the remote already: only content new@@ -170,11 +199,13 @@170199 		sent[file.hash] = true171200 		if backend.put_if_absent(object_key(repo, file.hash), store.object_blob(file.hash)!)! {172201 			report.objects++202+			uploaded.objects[file.hash] = true173203 		}174204 	}175205 	record := save_record(store, save)!176206 	if backend.put_if_absent(save_key(repo, save.id), encode_save(record).bytes())! {177207 		report.saves++208+		uploaded.saves << save.id178209 	}179210 }180211 

view file

src/exchange/exchange_test.v+77 −0
@@ -986,6 +986,83 @@986986 	assert 'keep.txt' in paths987987 }988988 989+// StripRace stands in for another machine that strips `path` completely, event990+// and rewrite, just before this machine's first save record lands.991+struct StripRace {992+mut:993+	inner remote.Backend994+	other &storage.Store995+	path  string996+	done  bool997+}998+999+fn (mut r StripRace) get(key string) !remote.Object {1000+	return r.inner.get(key)!1001+}1002+1003+fn (mut r StripRace) list(prefix string) ![]string {1004+	return r.inner.list(prefix)!1005+}1006+1007+fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1008+	if !r.done && key.contains('/saves/') {1009+		r.done = true1010+		strip_local(r.other, r.path, '')!1011+		send_strip(r.other, mut r.inner, r.path)!1012+	}1013+	return r.inner.put_if_absent(key, body)!1014+}1015+1016+fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1017+	return r.inner.put_if_match(key, body, tag)!1018+}1019+1020+fn (mut r StripRace) delete(key string) ! {1021+	r.inner.delete(key)!1022+}1023+1024+// A strip that finishes on another machine while an offer uploads has already1025+// rewritten the remote without the offer's records, so the offer takes back1026+// what the strip reaches and leaves head where it was.1027+fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1028+	base := scratch('strip_race')!1029+	defer {1030+		os.rmdir_all(base) or {}1031+	}1032+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1033+	mut a := open_repo(machine('strip_race_a')!)!1034+	defer {1035+		a.close()1036+	}1037+	mut b := open_repo(machine('strip_race_b')!)!1038+	defer {1039+		b.close()1040+	}1041+	put(a.root, 'secrets.env', 'api_key=1\n')!1042+	first := make_save(a, 'leak the key')!1043+	offer_now(a, mut backend)!1044+	join(b, mut backend)!1045+	put(a.root, 'secrets.env', 'api_key=2\n')!1046+	later := make_save(a, 'rotate the key')!1047+	secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1048+	mut race := StripRace{1049+		inner: backend1050+		other: &b1051+		path:  'secrets.env'1052+	}1053+	if _ := offer(a, mut race) {1054+		assert false, 'an offer that raced a strip must refuse'1055+	} else {1056+		assert err.msg().contains('koh steal'), err.msg()1057+	}1058+	assert backend.get(head_key('proj'))!.body.bytestr() == first.id1059+	assert !backend.get(object_key('proj', secret))!.found1060+	for key in backend.list('proj/saves')! {1061+		record := decode_save(backend.get(key)!.body)!1062+		assert 'secrets.env' !in record.files.map(it.path), record.id1063+	}1064+}1065+9891066 // The local half lands before the remote hears anything, so a remote failure9901067 // leaves nothing to find locally; the pending note is what lets a re-run9911068 // finish. Objects a remote-only save still needs must survive the strip.

view file

src/exchange/strip.v+11 −6
@@ -174,23 +174,28 @@174174 	return report175175 }176176 177-// unapplied_strip reports whether the remote holds a strip event this repo178-// has not received yet.179-fn unapplied_strip(store &storage.Store, mut backend remote.Backend, repo string) !bool {177+// unapplied_strips are the strip events on the remote this repo has not178+// received yet.179+fn unapplied_strips(store &storage.Store, mut backend remote.Backend, repo string) ![]events.Event {180180 	mut known := map[string]bool{}181181 	for event in events.all(store)! {182182 		known[event.id] = true183183 	}184+	mut strips := []events.Event{}184185 	for key in backend.list(events_prefix(repo))! {185186 		if os.base(key) in known {186187 			continue187188 		}188189 		body := backend.get(key)!189-		if body.found && decode_event(body.body)!.kind == events.kind_strip {190-			return true190+		if !body.found {191+			continue191192 		}193+		event := decode_event(body.body)!194+		if event.kind == events.kind_strip {195+			strips << event196+		}192197 	}193-	return false198+	return strips194199 }195200 196201 // rewrite returns the save record with a new file list.

view file

v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 	name: 'koh3'3-	version: '3.1.2'3+	version: '3.1.3'44 	description: 'One SQLite file per repo, one line of history.'55 	license: 'MIT'66 }

view file