sync_test.v

15.6 KB · raw

1//! Two-machine tests: two repos and one folder remote standing in for the Mac2//! and the PC. This is where offer, steal and combine are actually proven —3//! the folder backend exists so these run offline.45module sync67import os8import src.events9import src.history10import src.remote11import src.snapshot12import src.storage1314// Machines keep their repo in a folder of the same NAME, because the remote key15// is the folder's name; that is what makes them the same repo to the remote.16fn machine(name string) !string {17	dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}', 'proj')18	os.rmdir_all(os.dir(dir)) or {}19	os.mkdir_all(dir, os.MkdirParams{})!20	return dir21}2223fn scratch(name string) !string {24	dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}')25	os.rmdir_all(dir) or {}26	os.mkdir_all(dir, os.MkdirParams{})!27	return dir28}2930fn open_repo(dir string) !storage.Store {31	mut store := storage.create(dir)!32	os.write_file(os.join_path(dir, '.kohignore'), '*.log\n')!33	return store34}3536fn put(root string, rel string, text string) ! {37	full := os.join_path(root, rel)38	os.mkdir_all(os.dir(full), os.MkdirParams{})!39	os.write_file(full, text)!40}4142fn commit(store &storage.Store, message string) !history.Save {43	return history.commit_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{44		message: message45	})!46}4748fn offer_now(store &storage.Store, mut backend remote.Backend) !OfferReport {49	return offer(store, mut backend)!50}5152fn steal_now(store &storage.Store, mut backend remote.Backend) !StealReport {53	return steal(store, mut backend, StealOptions{54		theirs: true55	})!56}5758fn test_first_offer_publishes_the_whole_line() {59	base := scratch('first')!60	defer {61		os.rmdir_all(base) or {}62	}63	mut a := open_repo(machine('first_a')!)!64	defer {65		a.close()66	}67	put(a.root, 'a.txt', 'one\n')!68	commit(a, 'first')!69	put(a.root, 'a.txt', 'two\n')!70	commit(a, 'second')!71	mut backend := remote.new_folder(os.join_path(base, 'remote'))!7273	report := offer_now(a, mut backend)!74	assert report.repo == 'proj'75	assert report.saves == 276	assert report.objects >= 277	assert backend.get(head_key('proj'))!.found78	assert backend.list('proj/saves')!.len == 27980	// A second offer with nothing new uploads nothing and still succeeds.81	again := offer_now(a, mut backend)!82	assert again.saves == 083}8485fn test_the_repo_name_and_offered_head_are_remembered() {86	base := scratch('remember')!87	defer {88		os.rmdir_all(base) or {}89	}90	mut a := open_repo(machine('remember_a')!)!91	defer {92		a.close()93	}94	put(a.root, 'a.txt', 'one\n')!95	commit(a, 'first')!96	mut backend := remote.new_folder(os.join_path(base, 'remote'))!97	offer_now(a, mut backend)!98	assert a.state('remote_repo') or { '' } == 'proj'99	assert a.state('remote_head') or { '' } == history.head(a)!.id100101	// A second offer, with the remote already holding our head, must not102	// refuse: the name is remembered, so the remote is ours.103	put(a.root, 'a.txt', 'two\n')!104	commit(a, 'second')!105	offer_now(a, mut backend)!106	assert a.state('remote_head') or { '' } == history.head(a)!.id107}108109fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {110	base := scratch('forward')!111	defer {112		os.rmdir_all(base) or {}113	}114	mut backend := remote.new_folder(os.join_path(base, 'remote'))!115	mut a := open_repo(machine('fwd_a')!)!116	defer {117		a.close()118	}119	put(a.root, 'a.txt', 'from A\n')!120	commit(a, 'A writes')!121	offer_now(a, mut backend)!122123	// B starts empty and pulls the whole repo.124	mut b := open_repo(machine('fwd_b')!)!125	defer {126		b.close()127	}128	fetched := fetch_repo(mut backend, 'proj')!129	b.begin()!130	write_fetched(b, fetched)!131	history.set_head(b, fetched.head)!132	b.commit()!133	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!134	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'135	assert history.head(b)!.id == fetched.head136}137138fn test_divergence_without_clashes_keeps_both_machines_work() {139	base := scratch('diverge')!140	defer {141		os.rmdir_all(base) or {}142	}143	mut backend := remote.new_folder(os.join_path(base, 'remote'))!144	mut a := open_repo(machine('div_a')!)!145	defer {146		a.close()147	}148	mut b := open_repo(machine('div_b')!)!149	defer {150		b.close()151	}152	put(a.root, 'shared.txt', 'base\n')!153	commit(a, 'base')!154	offer_now(a, mut backend)!155156	// B joins, then both machines change a different file.157	join(b, mut backend)!158	put(a.root, 'fromA.txt', 'A\n')!159	commit(a, 'A adds a file')!160	put(b.root, 'fromB.txt', 'B\n')!161	commit(b, 'B adds a file')!162	offer_now(a, mut backend)!163164	// B's offer is refused: the remote has moved on.165	if _ := offer_now(b, mut backend) {166		assert false, 'offering on top of unknown remote saves should be refused'167	} else {168		assert err.msg().contains('run koh steal')169	}170171	report := steal_now(b, mut backend)!172	assert !report.forward173	assert report.replayed == 1174	assert report.clashes.len == 0175	// Both files are on B's line now, and on disk.176	assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'177	assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'178	assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'179	// B's own save was replayed, not erased.180	mut saw_replay := false181	for save in history.all_saves(b)! {182		if save.replayed_from != '' {183			saw_replay = true184		}185	}186	assert saw_replay187	// And the remote now has B's work too.188	offer_now(b, mut backend)!189	line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()190	assert line_head == history.head(b)!.id191}192193fn test_clashing_files_are_chosen_one_at_a_time() {194	base := scratch('clash')!195	defer {196		os.rmdir_all(base) or {}197	}198	mut backend := remote.new_folder(os.join_path(base, 'remote'))!199	mut a := open_repo(machine('clash_a')!)!200	defer {201		a.close()202	}203	mut b := open_repo(machine('clash_b')!)!204	defer {205		b.close()206	}207	put(a.root, 'same.txt', 'base\n')!208	put(a.root, 'other.txt', 'quiet\n')!209	commit(a, 'base')!210	offer_now(a, mut backend)!211	join(b, mut backend)!212213	// Both machines edit the same file, differently.214	put(a.root, 'same.txt', 'A version\n')!215	commit(a, 'A edits same')!216	offer_now(a, mut backend)!217	put(b.root, 'same.txt', 'B version\n')!218	commit(b, 'B edits same')!219220	// Without a decision, steal refuses and says which files clash.221	if _ := steal(b, mut backend, StealOptions{}) {222		assert false, 'an undecided clash must be refused'223	} else {224		assert err.msg().contains('--mine') || err.msg().contains('--theirs')225	}226	assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,227		history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0228229	// Choosing the remote's version ends with A's content.230	report := steal_now(b, mut backend)!231	assert report.clashes == ['same.txt']232	assert report.kept_mine == 0233	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'234	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'235}236237fn test_choosing_mine_keeps_the_local_version() {238	base := scratch('mine')!239	defer {240		os.rmdir_all(base) or {}241	}242	mut backend := remote.new_folder(os.join_path(base, 'remote'))!243	mut a := open_repo(machine('mine_a')!)!244	defer {245		a.close()246	}247	mut b := open_repo(machine('mine_b')!)!248	defer {249		b.close()250	}251	put(a.root, 'same.txt', 'base\n')!252	commit(a, 'base')!253	offer_now(a, mut backend)!254	join(b, mut backend)!255	put(a.root, 'same.txt', 'A version\n')!256	commit(a, 'A edits')!257	offer_now(a, mut backend)!258	put(b.root, 'same.txt', 'B version\n')!259	commit(b, 'B edits')!260261	report := steal(b, mut backend, StealOptions{262		mine: true263	})!264	assert report.kept_mine == 1265	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'266	// The final save says what it kept.267	assert history.head(b)!.message.contains('combine: kept mine for 1 file')268	// The pre-combine tree is still reachable in history.269	assert history.head(b)!.restores == ''270	assert history.all_saves(b)!.len >= 4271}272273fn test_flags_travel_with_the_saves() {274	base := scratch('flags')!275	defer {276		os.rmdir_all(base) or {}277	}278	mut backend := remote.new_folder(os.join_path(base, 'remote'))!279	mut a := open_repo(machine('flag_a')!)!280	defer {281		a.close()282	}283	mut b := open_repo(machine('flag_b')!)!284	defer {285		b.close()286	}287	put(a.root, 'a.txt', 'one\n')!288	save := commit(a, 'suspect save')!289	events.flag(a, save.id, 'look at this')!290	offer_now(a, mut backend)!291292	// B has nothing yet, so a fetch brings the save and its flag across.293	fetched := fetch_back_to_known(b, mut backend, 'proj')!294	assert fetched.events_out.len == 1295	assert fetched.events_out[0].kind == events.kind_flag296	b.begin()!297	write_fetched(b, fetched)!298	history.set_head(b, fetched.head)!299	b.commit()!300	state := events.flag_state(b, save.id)!301	assert state.flagged302	assert state.reason == 'look at this'303}304305fn test_a_second_offer_racing_head_is_refused_not_forced() {306	base := scratch('race')!307	defer {308		os.rmdir_all(base) or {}309	}310	mut backend := remote.new_folder(os.join_path(base, 'remote'))!311	mut a := open_repo(machine('race_a')!)!312	defer {313		a.close()314	}315	put(a.root, 'a.txt', 'one\n')!316	commit(a, 'base')!317	offer_now(a, mut backend)!318319	// Someone else replaces head behind our back.320	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),321		backend.get(head_key('proj'))!.tag)!322323	put(a.root, 'a.txt', 'two\n')!324	commit(a, 'second')!325	if _ := offer_now(a, mut backend) {326		assert false, 'offering while the remote head moved must be refused'327	} else {328		assert err.msg().contains('run koh steal')329	}330}331332// join makes a brand new machine share the remote's line.333fn join(store &storage.Store, mut backend remote.Backend) ! {334	fetched := fetch_repo(mut backend, 'proj')!335	store.begin()!336	write_fetched(store, fetched)!337	history.set_head(store, fetched.head)!338	store.commit()!339	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,340		fetched.head)!, [])!341	store.set_state('remote_repo', 'proj')!342}343344struct FaultyBackend {345mut:346	inner      remote.Backend347	writes     int348	reads      int349	fail_write int // fail when the write counter reaches this, 0 never350	fail_read  int351}352353fn (mut f FaultyBackend) get(key string) !remote.Object {354	f.reads++355	if f.fail_read > 0 && f.reads >= f.fail_read {356		return error('injected read failure')357	}358	return f.inner.get(key)!359}360361fn (mut f FaultyBackend) list(prefix string) ![]string {362	return f.inner.list(prefix)!363}364365fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {366	f.writes++367	if f.fail_write > 0 && f.writes >= f.fail_write {368		return error('injected write failure')369	}370	return f.inner.put_if_absent(key, body)!371}372373fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {374	f.writes++375	if f.fail_write > 0 && f.writes >= f.fail_write {376		return error('injected write failure')377	}378	return f.inner.put_if_match(key, body, tag)!379}380381fn (mut f FaultyBackend) delete(key string) ! {382	f.inner.delete(key)!383}384385fn test_a_failed_offer_is_safe_to_re_run() {386	base := scratch('fault_offer')!387	defer {388		os.rmdir_all(base) or {}389	}390	remote_dir := os.join_path(base, 'remote')391	mut a := open_repo(machine('fault_a')!)!392	defer {393		a.close()394	}395	put(a.root, 'a.txt', 'one\n')!396	commit(a, 'first')!397	put(a.root, 'a.txt', 'two\n')!398	commit(a, 'second')!399400	// The first offer dies after the first write.401	mut flaky := FaultyBackend{402		inner:      remote.new_folder(remote_dir)!403		fail_write: 2404	}405	if _ := offer(a, mut flaky) {406		assert false, 'the injected failure should have surfaced'407	}408409	// Re-running with a healthy remote finishes the job: "already exists" is410	// success, so nothing is lost and nothing is duplicated.411	mut healthy := remote.new_folder(remote_dir)!412	report := offer(a, mut healthy)!413	assert report.saves == 2414	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id415	assert healthy.list('proj/saves')!.len == 2416}417418fn test_a_failed_steal_leaves_nothing_behind() {419	base := scratch('fault_steal')!420	defer {421		os.rmdir_all(base) or {}422	}423	remote_dir := os.join_path(base, 'remote')424	mut a := open_repo(machine('fault_s_a')!)!425	defer {426		a.close()427	}428	mut b := open_repo(machine('fault_s_b')!)!429	defer {430		b.close()431	}432	put(a.root, 'a.txt', 'from A\n')!433	commit(a, 'A writes')!434	mut healthy := remote.new_folder(remote_dir)!435	offer(a, mut healthy)!436437	// B's fetch fails half way through reading the remote.438	mut flaky := FaultyBackend{439		inner:     remote.new_folder(remote_dir)!440		fail_read: 2441	}442	if _ := fetch_back_to_known(b, mut flaky, 'proj') {443		assert false, 'the injected read failure should have surfaced'444	}445	// Nothing was written: the store is untouched and still has no saves.446	assert history.head(b) == none447	assert b.object_count()! == 0448449	// A healthy retry works.450	fetched := fetch_back_to_known(b, mut healthy, 'proj')!451	b.begin()!452	write_fetched(b, fetched)!453	history.set_head(b, fetched.head)!454	b.commit()!455	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!456	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'457}458459fn test_an_interrupted_strip_is_idempotent() {460	// The local half of a strip is one transaction, so an interrupted strip is461	// simply a strip that did not happen; re-running it must land the same462	// result once, not twice.463	base := scratch('fault_strip')!464	defer {465		os.rmdir_all(base) or {}466	}467	mut a := open_repo(machine('fault_strip_a')!)!468	defer {469		a.close()470	}471	put(a.root, 'secret.txt', 'shh\n')!472	put(a.root, 'keep.txt', 'fine\n')!473	save := commit(a, 'add both')!474475	// What `koh strip` does locally, twice over.476	for _ in 0 .. 2 {477		a.begin()!478		mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')!479		stmt.bind_text(1, 'secret.txt')!480		stmt.step()!481		stmt.finalize()482		a.drop_unreferenced_objects()!483		events.strip(a, '', 'secret.txt')!484		a.commit()!485	}486	files := history.files_of(a, save.id)!487	// The repo's own `.kohignore` is tracked content, so it stays; only the488	// stripped path goes.489	assert files.map(it.path) == ['.kohignore', 'keep.txt']490	assert events.strip_events(a)!.len == 1491}492493fn test_a_strip_reaches_the_remote_and_other_machines() {494	base := scratch('strip_sync')!495	defer {496		os.rmdir_all(base) or {}497	}498	mut a := open_repo(machine('strip_a')!)!499	defer {500		a.close()501	}502	mut b := open_repo(machine('strip_b')!)!503	defer {504		b.close()505	}506	put(a.root, 'secrets.env', 'api_key=1\n')!507	put(a.root, 'keep.txt', 'fine\n')!508	save := commit(a, 'add both')!509	mut backend := remote.new_folder(os.join_path(base, 'remote'))!510	offer_now(a, mut backend)!511	join(b, mut backend)!512	assert b.object_count()! > 0513514	// A strips the path locally, the way `koh strip` does, then tells the515	// remote.516	mut secret_hash := ''517	for file in history.files_of(a, save.id)! {518		if file.path == 'secrets.env' {519			secret_hash = file.hash520		}521	}522	assert secret_hash != ''523	a.begin()!524	mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')!525	stmt.bind_text(1, 'secrets.env')!526	stmt.step()!527	stmt.finalize()528	a.drop_unreferenced_objects()!529	events.strip(a, '', 'secrets.env')!530	a.commit()!531	report := push_strip(a, mut backend, 'secrets.env', [save.id], [secret_hash], true)!532	assert report.event_sent533	assert report.records == 1534535	// The remote's copy of the save no longer lists the file, and the object is536	// gone from the remote.537	record := decode_save(backend.get(save_key('proj', save.id))!.body)!538	for file in record.files {539		assert file.path != 'secrets.env'540	}541	// The stripped object is gone from the remote; the ones still referenced542	// are not.543	assert !backend.get(object_key('proj', secret_hash))!.found544	assert backend.list('proj/objects')!.len == 2545546	// Another machine hears about it on its next steal and applies it.547	b.begin()!548	fetched := fetch_back_to_known(b, mut backend, 'proj')!549	write_fetched(b, fetched)!550	b.commit()!551	mut paths := []string{}552	for file in history.files_of(b, save.id)! {553		paths << file.path554	}555	assert 'secrets.env' !in paths556	assert 'keep.txt' in paths557}