Skip to content

Commit 4e01eb8

Browse files
fix(build): a record being published is waited out, and only an ended process is a lost controller
On Windows the name a rename installs can be refused to a reader for a short interval. The waiter read that refusal as an unreadable controller record and declared a running compiler lost: 10 of 40 Windows-host cross builds failed this way (#890). Records and statuses are now opened with a bounded backoff (kRecordSettle, 250 ms) while the name exists; an absent name is absent at once and a refusal that outlasts the bound is an error. The controller's state is three-valued: only an identity that names an ended process is ended; an unreadable record is unknown and the waiter keeps waiting within its existing bounds. A loss is reported with the observation that decided it. copy_atomic publishes through the host namespace like every other record of the module, so a name in Windows' long spelling stays valid. A cancelled managed supervisor writes its status, and a registration failure reaches the supervisor log. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
1 parent 2e6284f commit 4e01eb8

4 files changed

Lines changed: 187 additions & 26 deletions

File tree

‎src/build/schedule/compiler_transaction.cppm‎

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,38 @@ std::string pack(std::string_view tag, const Fields& fields) {
4040
// Atomic visibility across process loss; storage durability is not promised.
4141
bool replace_record(const fs::path& path,std::string_view bytes,std::string* diagnostic=nullptr);
4242
bool publish(const fs::path& path,std::string_view tag,const Fields& fields);
43-
Record read(const fs::path& path, std::string_view tag) {
44-
std::ifstream input(path,std::ios::binary);
45-
if (!input) {
43+
// A RECORD THAT EXISTS AND CANNOT YET BE OPENED IS BEING PUBLISHED. On Windows
44+
// the name a rename installs can be refused to a reader for a short interval
45+
// (the rename's own handle, or a scanner holding the new file); a reader that
46+
// took that refusal as the record's content declared a live compiler lost
47+
// (mcpp-community/mcpp#889, measured 10 of 40 Windows cross builds). The open
48+
// is retried with backoff for at most kRecordSettle; a name that is absent is
49+
// absent at once, and a refusal that outlasts the bound is still an error.
50+
inline constexpr auto kRecordSettle=std::chrono::milliseconds(250);
51+
enum class Opened { yes, absent, refused };
52+
// Opens `path' for reading, waiting out a publication in progress.
53+
Opened open_settled(std::ifstream& input,const fs::path& path) {
54+
input.open(path,std::ios::binary);
55+
if(input)return Opened::yes;
56+
const auto limit=std::chrono::steady_clock::now()+kRecordSettle;
57+
auto pause=std::chrono::milliseconds(1);
58+
for(;;) {
4659
std::error_code ec;
47-
if (!fs::exists(path,ec) && !ec) return std::optional<Fields>{};
48-
if(ec)return std::unexpected("cannot read compiler transaction record");
49-
// An atomic publisher may install the record between the failed open
50-
// and the existence probe. Retry once; a persistent refusal still
51-
// fails rather than being mistaken for an absent record.
60+
const bool present=fs::exists(path,ec);
61+
if(!present&&!ec)return Opened::absent;
62+
input.clear();
5263
input.open(path,std::ios::binary);
53-
if(!input)return std::unexpected("cannot read compiler transaction record");
64+
if(input)return Opened::yes;
65+
if(std::chrono::steady_clock::now()>=limit)return Opened::refused;
66+
std::this_thread::sleep_for(pause);
67+
pause=std::min(pause*2,std::chrono::milliseconds(32));
5468
}
69+
}
70+
Record read(const fs::path& path, std::string_view tag) {
71+
std::ifstream input;
72+
const auto opened=open_settled(input,path);
73+
if(opened==Opened::absent)return std::optional<Fields>{};
74+
if(opened==Opened::refused)return std::unexpected("cannot read compiler transaction record");
5575
std::string bytes(65537,'\0');
5676
input.read(bytes.data(),bytes.size());bytes.resize(static_cast<std::size_t>(input.gcount()));
5777
if(input.bad())return std::unexpected("cannot read compiler transaction record");
@@ -118,7 +138,11 @@ bool copy_atomic(const fs::path& from,const fs::path& to) {
118138
fs::copy_file(from,temp,fs::copy_options::overwrite_existing,ec);if(ec)return false;
119139
const auto time=fs::last_write_time(from,ec);if(ec)return false;
120140
fs::last_write_time(temp,time,ec);if(ec)return false;
121-
return bool(os::fs::rename(temp,to,true));
141+
// The host namespace, as for every record of this module (the copy was
142+
// written through it); a name in Windows' long spelling stays valid.
143+
fs::rename(temp,to,ec);
144+
if(ec) {fs::remove(temp,ec);return false;}
145+
return true;
122146
}
123147
struct Attempt {
124148
fs::path directory, bmi, object, slot, dep;
@@ -299,17 +323,26 @@ bool release_reservation(const fs::path& reservation,const fs::path& attempt) {
299323
if((**current)[0]!=spelling(attempt))return true; // a later, separately locked slot
300324
std::error_code ec;fs::remove(reservation,ec);return !ec;
301325
}
302-
bool controller_alive(const Attempt& attempt) {
326+
// WHETHER A CONTROLLER IS RUNNING IS A QUESTION ABOUT A PROCESS, NOT ABOUT A
327+
// FILE. Only an identity that names an ended process answers `ended'; a record
328+
// that cannot be read yet answers `unknown', and the caller keeps waiting
329+
// within its own bounds. A record that is absent is the existing startup case.
330+
enum class Controller { running, ended, unknown };
331+
Controller controller_state(const Attempt& attempt) {
303332
auto controller=read(attempt.directory/"controller","mcpp-controller-v1");
304-
if(!controller)return false;
305-
if(!*controller)return true; // the existing startup bound applies
306-
if((**controller).size()!=3)return false;
333+
if(!controller)return Controller::unknown;
334+
if(!*controller)return Controller::running; // the existing startup bound applies
335+
if((**controller).size()!=3)return Controller::ended;
307336
os::process::program who{};const auto& fields=**controller;
308337
const auto id=std::from_chars(fields[0].data(),fields[0].data()+fields[0].size(),who.id);
309338
const auto birth=std::from_chars(fields[1].data(),fields[1].data()+fields[1].size(),who.started_ns);
310-
return id.ec==std::errc{}&&id.ptr==fields[0].data()+fields[0].size()
311-
&&birth.ec==std::errc{}&&birth.ptr==fields[1].data()+fields[1].size()
312-
&&who.id&&who.started_ns&&os::process::alive(who);
339+
const bool valid=id.ec==std::errc{}&&id.ptr==fields[0].data()+fields[0].size()
340+
&&birth.ec==std::errc{}&&birth.ptr==fields[1].data()+fields[1].size()&&who.id&&who.started_ns;
341+
if(!valid)return Controller::ended;
342+
return os::process::alive(who) ? Controller::running : Controller::ended;
343+
}
344+
std::string_view describe(Controller c) {
345+
return c==Controller::running ? "running" : c==Controller::ended ? "ended" : "unknown";
313346
}
314347

315348
std::expected<std::string,std::string> receive(os::ipc::connection& connection,std::chrono::milliseconds bound) {
@@ -399,7 +432,9 @@ private:
399432
if(*retired) {it=held_.erase(it);continue;}
400433
auto attempt=Attempt::load(directory);
401434
if(!attempt) {fail(attempt.error());++it;continue;}
402-
if(!controller_alive(*attempt)) {
435+
const auto state=controller_state(*attempt);
436+
if(state==Controller::unknown) {++it;continue;} // asked again at the next audit
437+
if(state==Controller::ended) {
403438
// Retirement can be published between the first read and the
404439
// controller's exit. Observe it again before reporting loss.
405440
retired=read(directory/"retired","mcpp-retired-v1");
@@ -430,7 +465,9 @@ private:
430465
const auto actor=named((**unit)[3]);
431466
if(actor.parent_path()!=generation_/"actors") {send(connection,"REFUSED");continue;}
432467
auto lease=os::fs::lock::acquire(actor,os::fs::lock::kind::exclusive,false);
433-
if(lease||lease.error().code!=os::errc::again||!controller_alive(*prepared)) {send(connection,"REFUSED");continue;}
468+
// The actor's lock is held, so its controller is running; a record
469+
// still settling does not refuse the registration.
470+
if(lease||lease.error().code!=os::errc::again||controller_state(*prepared)==Controller::ended) {send(connection,"REFUSED");continue;}
434471
auto retained=os::process::unit_observer::open((**unit)[0]);
435472
if(!retained) {send(connection,"REFUSED");continue;}
436473
std::lock_guard guard(admission_);

‎src/build/schedule/detach_codegen.cppm‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -299,8 +299,8 @@ void withdraw_outputs(const std::filesystem::path& slot,
299299
// the supervisor's status is lost, and the unit is taken as failed rather than
300300
// waited for until the hard limit.
301301
std::optional<int> read_rc(const std::filesystem::path& slot) {
302-
std::ifstream in(suffixed(slot, ".rc"));
303-
if (!in) return std::nullopt;
302+
std::ifstream in;
303+
if (transactions::open_settled(in, suffixed(slot, ".rc")) != transactions::Opened::yes) return std::nullopt;
304304
int rc = 0;
305305
if (!(in >> rc)) {
306306
std::println(std::cerr, "mcpp: the status of the compiler for {} was not written "
@@ -548,6 +548,15 @@ void record_supervisor_error(const std::filesystem::path& slot,std::string_view
548548
if(log)log << "mcpp: " << reason << '\n';
549549
}
550550

551+
// What the waiter observed when it concluded that the controller is gone: one
552+
// line, without a path, so a failure states which observation decided it.
553+
void report_controller_loss(const std::filesystem::path& slot,transactions::Controller controller,bool supervising) {
554+
std::error_code ec;
555+
const bool log=std::filesystem::exists(suffixed(slot,".log"),ec);
556+
std::println(std::cerr,"mcpp: compiler controller observation: controller {}, supervisor {}, status absent, log {}",
557+
transactions::describe(controller),supervising ? "running" : "ended",log ? "present" : "absent");
558+
}
559+
551560
void replay_supervisor_log(const std::filesystem::path& slot) {
552561
std::ifstream log(suffixed(slot,".log"));
553562
if(log)std::cerr << log.rdbuf();
@@ -827,12 +836,14 @@ int compile_release_at_bmi(const CompileRequest& req) {
827836
}
828837
if(attempt && clock::now()>=nextControllerCheck) {
829838
nextControllerCheck=clock::now()+std::chrono::milliseconds(50);
830-
if(!transactions::controller_alive(*attempt)
831-
||(supervisor&&!mcpplibs::os::process::alive(*supervisor))) {
839+
const auto controller=transactions::controller_state(*attempt);
840+
const bool supervising=!supervisor||mcpplibs::os::process::alive(*supervisor);
841+
if(controller==transactions::Controller::ended||!supervising) {
832842
// The final status can be published between the first read and
833843
// this liveness observation. The next iteration validates it.
834844
if(read_rc(actual.slot))continue;
835845
replay_supervisor_log(actual.slot);
846+
report_controller_loss(actual.slot,controller,supervising);
836847
std::println(std::cerr,"mcpp: compiler controller ended without a final status; retaining its outputs until unit recovery");
837848
return 1;
838849
}
@@ -885,9 +896,10 @@ int supervise(const std::filesystem::path& slot,
885896
auto unit=std::make_shared<mcpplibs::os::process::owned_unit>(std::move(*created));
886897
InterruptCleanup cleanup{mcpp::run::add_cleanup([actor,unit] {unit->terminate();})};
887898
if(auto registered=attempt.register_unit(*unit,actor->path,permit->reservation);!registered) {
888-
std::println(std::cerr,"mcpp: {}",registered.error());(void)write_rc(slot,2);return 2;
899+
record_supervisor_error(slot,registered.error());(void)write_rc(slot,2);return 2;
889900
}
890-
if(!actor->owner.active())return 130;
901+
// Every exit leaves a status, a cancelled one included.
902+
if(!actor->owner.active()) {(void)write_rc(slot,130);return 130;}
891903
if(!transactions::publish(attempt.directory/"admitted","mcpp-admitted-v1",{})) {
892904
(void)write_rc(slot,2);return 2;
893905
}
@@ -982,11 +994,13 @@ int await_unit(const std::filesystem::path& slot, const std::filesystem::path& o
982994
if (!actor.owner.active()) return 130;
983995
if(attempt&&clock::now()>=nextControllerCheck) {
984996
nextControllerCheck=clock::now()+std::chrono::milliseconds(50);
985-
if(!transactions::controller_alive(*attempt)) {
997+
const auto controller=transactions::controller_state(*attempt);
998+
if(controller==transactions::Controller::ended) {
986999
// A controller may publish its status and exit after the read
9871000
// in the loop condition. Let the normal result path inspect it.
9881001
if((rc=read_rc(slot)))break;
9891002
replay_supervisor_log(slot);
1003+
report_controller_loss(slot,controller,true);
9901004
std::println(std::cerr,"mcpp: compiler controller ended without a status; unit recovery will retain or invalidate its outputs");
9911005
return 1;
9921006
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#define _GNU_SOURCE
2+
#include <dlfcn.h>
3+
#include <errno.h>
4+
#include <stdio.h>
5+
#include <stdlib.h>
6+
#include <string.h>
7+
8+
/* Refuses opening an existing record for reading, as Windows refuses a name a
9+
* rename has just installed: MCPP_TEST_REFUSE_NAME names the file and
10+
* MCPP_TEST_REFUSE_COUNT how many opens are refused (-1: every one). Each
11+
* refusal appends a line to `<name>-refused`. */
12+
static FILE *(*native_open)(const char *, const char *);
13+
static long refused;
14+
15+
FILE *fopen64(const char *path, const char *mode) {
16+
if (!native_open) native_open = dlsym(RTLD_NEXT, "fopen64");
17+
const char *name = getenv("MCPP_TEST_REFUSE_NAME");
18+
const char *count = getenv("MCPP_TEST_REFUSE_COUNT");
19+
const long limit = count ? strtol(count, NULL, 10) : 0;
20+
if (name && strcmp(path, name) == 0 && mode[0] == 'r' && (limit < 0 || refused < limit)) {
21+
++refused;
22+
char marker[512];
23+
snprintf(marker, sizeof marker, "%s-refused", name);
24+
FILE *m = native_open(marker, "ab");
25+
if (m) { fputs("refused\n", m); fclose(m); }
26+
errno = EACCES;
27+
return NULL;
28+
}
29+
return native_open(path, mode);
30+
}
31+
32+
FILE *fopen(const char *path, const char *mode) { return fopen64(path, mode); }

‎tests/unit/test_compiler_transaction.cpp‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,84 @@ TEST(CompilerTransaction, RecordAppearanceBetweenOpenAndExistsIsNotCorruption) {
288288
EXPECT_EQ(result.exit_code,0)<<result.output;
289289
EXPECT_TRUE(fs::exists(t.root/"record-open-interposed"));
290290
}
291+
TEST(CompilerTransaction, RecordRefusalDriver) {
292+
if(!mcpp::invocation::get("MCPP_TEST_RECORD_REFUSAL_DRIVER"))GTEST_SKIP()<<"subprocess driver";
293+
const auto started=std::chrono::steady_clock::now();
294+
auto record=tx::read("record","schema");
295+
const auto elapsed=std::chrono::steady_clock::now()-started;
296+
const bool forever=mcpp::invocation::get("MCPP_TEST_REFUSE_COUNT").value_or("")=="-1";
297+
if(forever) {
298+
EXPECT_FALSE(record);
299+
EXPECT_LT(elapsed,std::chrono::seconds(2));
300+
} else {
301+
ASSERT_TRUE(record&&*record);
302+
EXPECT_EQ((**record)[0],"published");
303+
}
304+
}
305+
TEST(CompilerTransaction, ControllerRefusalDriver) {
306+
if(!mcpp::invocation::get("MCPP_TEST_CONTROLLER_REFUSAL_DRIVER"))GTEST_SKIP()<<"subprocess driver";
307+
auto attempt=tx::Attempt::load(fs::path(*mcpp::invocation::get("MCPP_TEST_ATTEMPT")));
308+
ASSERT_TRUE(attempt)<<attempt.error();
309+
EXPECT_EQ(tx::controller_state(*attempt),tx::Controller::unknown);
310+
}
311+
namespace {
312+
// Runs one driver of this binary with the refusal interposer preloaded.
313+
std::optional<mcpp::run::RunResult> refused_driver(const fs::path& root,std::string_view filter,
314+
std::vector<std::pair<std::string,std::string>> env) {
315+
const auto library=root/"record-open-refusal.so";
316+
const auto source=fs::current_path()/"tests/e2e/helpers/record_open_refusal.c";
317+
auto compiled=mcpp::run::execute({"/usr/bin/cc","-shared","-fPIC","-Wall","-Wextra","-Werror",
318+
source.string(),"-o",library.string(),"-ldl"},{.total=std::chrono::seconds(10)});
319+
if(compiled.exit_code!=0) {ADD_FAILURE()<<compiled.output;return std::nullopt;}
320+
env.push_back({"LD_PRELOAD",library.string()});
321+
return mcpp::run::execute({mcpp::invocation::self_exe_path().string(),
322+
std::format("--gtest_filter=CompilerTransaction.{}",filter)},
323+
{.env=env,.cwd=root.string(),.total=std::chrono::seconds(20)});
324+
}
325+
bool preloadable() {
326+
if constexpr (!mcpplibs::os::is_linux)return false;
327+
const auto runtime=mcpp::runtime::elf::inspect_elf_runtime(mcpp::invocation::self_exe_path());
328+
return runtime && runtime->interp.find("ld-linux")!=std::string::npos;
329+
}
330+
std::size_t refusals(const fs::path& marker) {
331+
std::ifstream in(marker);std::size_t n=0;std::string line;
332+
while(std::getline(in,line))++n;
333+
return n;
334+
}
335+
} // namespace
336+
TEST(CompilerTransaction, RecordRefusedWhilePublishedIsWaitedOut) {
337+
if(!preloadable())GTEST_SKIP()<<"open interposition requires Linux and the dynamic glibc loader";
338+
Transaction t;
339+
ASSERT_TRUE(tx::publish(t.root/"record","schema",{"published"}));
340+
const auto result=refused_driver(t.root,"RecordRefusalDriver",
341+
{{"MCPP_TEST_RECORD_REFUSAL_DRIVER","1"},{"MCPP_TEST_REFUSE_NAME","record"},{"MCPP_TEST_REFUSE_COUNT","4"}});
342+
ASSERT_TRUE(result);
343+
EXPECT_EQ(result->exit_code,0)<<result->output;
344+
// Four refusals: the reader before 2026.11.7.1 retried once and failed.
345+
EXPECT_EQ(refusals(t.root/"record-refused"),4u);
346+
}
347+
TEST(CompilerTransaction, PersistentRefusalIsABoundedError) {
348+
if(!preloadable())GTEST_SKIP()<<"open interposition requires Linux and the dynamic glibc loader";
349+
Transaction t;
350+
ASSERT_TRUE(tx::publish(t.root/"record","schema",{"published"}));
351+
const auto result=refused_driver(t.root,"RecordRefusalDriver",
352+
{{"MCPP_TEST_RECORD_REFUSAL_DRIVER","1"},{"MCPP_TEST_REFUSE_NAME","record"},{"MCPP_TEST_REFUSE_COUNT","-1"}});
353+
ASSERT_TRUE(result);
354+
EXPECT_EQ(result->exit_code,0)<<result->output;
355+
EXPECT_GE(refusals(t.root/"record-refused"),2u);
356+
}
357+
TEST(CompilerTransaction, UnreadableControllerRecordIsNotAnEndedController) {
358+
if(!preloadable())GTEST_SKIP()<<"open interposition requires Linux and the dynamic glibc loader";
359+
Transaction t;
360+
auto a=t.prepare();
361+
ASSERT_TRUE(tx::publish(a.directory/"controller","mcpp-controller-v1",{"1","1",tx::spelling(t.generation/"actors/helper.lock")}));
362+
const auto result=refused_driver(t.root,"ControllerRefusalDriver",
363+
{{"MCPP_TEST_CONTROLLER_REFUSAL_DRIVER","1"},{"MCPP_TEST_ATTEMPT",a.directory.string()},
364+
{"MCPP_TEST_REFUSE_NAME",(a.directory/"controller").string()},
365+
{"MCPP_TEST_REFUSE_COUNT","-1"}});
366+
ASSERT_TRUE(result);
367+
EXPECT_EQ(result->exit_code,0)<<result->output;
368+
}
291369
TEST(CompilerTransaction, FailedCloseCannotPublishOrAcknowledgeARecord) {
292370
if constexpr (!mcpplibs::os::is_linux)GTEST_SKIP()<<"Linux libc close failure control";
293371
const auto runtime=mcpp::runtime::elf::inspect_elf_runtime(mcpp::invocation::self_exe_path());

0 commit comments

Comments
 (0)