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
↓ koh-66eb40a2.tar.gzatlas at this save
files changed5 files
VERSION+1 −1src/exchange/exchange.v+38 −7src/exchange/exchange_test.v+77 −0src/exchange/strip.v+11 −6v.mod+1 −1src/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 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.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.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 }