1use std::fs::{File, OpenOptions};
12use std::io::{Read, Write};
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::sync::{LazyLock, Mutex, OnceLock};
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17
18use anyhow::{Context, Result, bail};
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21
22const STARTUP_SECONDS: u64 = 30;
23const RETAIN_FINISHED: usize = 256;
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Status {
29 Starting,
31 Running,
33 Finished,
35 Failed,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum Control {
43 Park,
45 Stop,
47}
48
49#[derive(Debug)]
50struct NotStarted(anyhow::Error);
51
52impl std::fmt::Display for NotStarted {
53 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 write!(formatter, "{:#}", self.0)
55 }
56}
57
58impl std::error::Error for NotStarted {
59 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
60 Some(self.0.as_ref())
61 }
62}
63
64fn not_started_error(error: impl Into<anyhow::Error>) -> anyhow::Error {
65 anyhow::Error::new(NotStarted(error.into()))
66}
67
68pub fn not_started(error: &anyhow::Error) -> bool {
74 error.downcast_ref::<NotStarted>().is_some()
75}
76
77#[derive(Clone, Debug, Serialize, Deserialize)]
79pub struct Job {
80 pub id: String,
82 #[serde(default)]
84 pub sequence: u64,
85 pub kind: String,
87 pub key: String,
89 pub payload: serde_json::Value,
91 pub generation: String,
93 #[serde(default)]
95 pub version: Option<String>,
96 pub status: Status,
98 pub result: Option<String>,
100 pub pid: Option<u32>,
102 pub created_at: u64,
104 pub finished_at: Option<u64>,
106}
107
108#[derive(Clone, Debug)]
110pub struct Generation {
111 pub id: String,
113 pub executable: PathBuf,
115}
116
117#[derive(Debug)]
119pub struct Lease {
120 _file: File,
121}
122
123impl Drop for Lease {
124 fn drop(&mut self) {
125 #[cfg(unix)]
128 {
129 use std::os::fd::AsRawFd;
130 unsafe { libc::flock(self._file.as_raw_fd(), libc::LOCK_UN) };
131 }
132 }
133}
134
135fn root(home: &Path) -> PathBuf {
136 home.join("workers")
137}
138fn now() -> u64 {
139 SystemTime::now()
140 .duration_since(UNIX_EPOCH)
141 .unwrap_or_default()
142 .as_secs()
143}
144fn nonce() -> String {
145 crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4()
146}
147fn valid_id(id: &str) -> bool {
148 !id.is_empty() && id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-')
149}
150fn job_dir(home: &Path, id: &str) -> Result<PathBuf> {
151 if !valid_id(id) {
152 bail!("invalid worker id");
153 }
154 Ok(root(home).join("jobs").join(id))
155}
156
157fn open_lock(path: &Path) -> Result<File> {
158 if let Some(parent) = path.parent() {
159 std::fs::create_dir_all(parent)
160 .with_context(|| format!("create worker lock directory {}", parent.display()))?;
161 }
162 OpenOptions::new()
163 .create(true)
164 .truncate(false)
165 .read(true)
166 .write(true)
167 .open(path)
168 .with_context(|| format!("open worker lock {}", path.display()))
169}
170
171fn try_lock(path: &Path) -> Result<Option<Lease>> {
172 let file = open_lock(path)?;
173 match crate::pr_head::try_lock_file(&file) {
174 Ok(()) => Ok(Some(Lease { _file: file })),
175 Err(e)
176 if e.kind() == std::io::ErrorKind::WouldBlock
177 || matches!(e.raw_os_error(), Some(33)) =>
178 {
179 Ok(None)
180 }
181 Err(e) => Err(e).with_context(|| format!("acquire worker lock {}", path.display())),
182 }
183}
184
185pub fn try_lease(home: &Path, name: &str) -> Result<Option<Lease>> {
188 try_lock(
189 &root(home)
190 .join("leases")
191 .join(format!("{}.lock", digest(name.as_bytes()))),
192 )
193}
194
195fn store_lock(home: &Path) -> Result<Lease> {
196 let deadline = std::time::Instant::now() + Duration::from_secs(10);
197 loop {
198 if let Some(lease) = try_lock(&root(home).join("store.lock"))? {
199 return Ok(lease);
200 }
201 if std::time::Instant::now() >= deadline {
202 bail!("worker store is busy; retry later");
203 }
204 std::thread::sleep(Duration::from_millis(5));
205 }
206}
207
208#[cfg(unix)]
209fn sync_dir(path: &Path) -> Result<()> {
210 File::open(path)
211 .with_context(|| format!("open worker directory for sync {}", path.display()))?
212 .sync_all()
213 .with_context(|| format!("sync worker directory {}", path.display()))?;
214 Ok(())
215}
216
217#[cfg(not(unix))]
218fn sync_dir(_path: &Path) -> Result<()> {
219 Ok(())
220}
221
222fn save(path: &Path, value: &impl Serialize) -> Result<()> {
223 let parent = path.parent().context("worker record has no parent")?;
224 std::fs::create_dir_all(parent)
225 .with_context(|| format!("create worker record directory {}", parent.display()))?;
226 let temp = parent.join(format!(".pending-{}", nonce()));
227 let result = (|| -> Result<()> {
228 let mut file = OpenOptions::new()
229 .create_new(true)
230 .write(true)
231 .open(&temp)
232 .with_context(|| format!("create staged worker record {}", temp.display()))?;
233 serde_json::to_writer(&mut file, value)
234 .with_context(|| format!("serialize worker record {}", path.display()))?;
235 file.flush()
236 .with_context(|| format!("flush staged worker record {}", temp.display()))?;
237 file.sync_all()
238 .with_context(|| format!("sync staged worker record {}", temp.display()))?;
239 drop(file);
242 replace(&temp, path).with_context(|| {
243 format!(
244 "publish worker record {} -> {}",
245 temp.display(),
246 path.display()
247 )
248 })?;
249 sync_dir(parent)
250 })();
251 if result.is_err() {
252 let _ = std::fs::remove_file(&temp);
253 }
254 result
255}
256
257#[cfg(not(windows))]
258fn replace(from: &Path, to: &Path) -> Result<()> {
259 Ok(std::fs::rename(from, to)?)
260}
261
262#[cfg(windows)]
263fn replace(from: &Path, to: &Path) -> Result<()> {
264 crate::lock_file::Retry::new(None).run(|| std::fs::rename(from, to))?;
269 Ok(())
270}
271
272fn open_record(path: &Path) -> std::io::Result<File> {
275 #[cfg(windows)]
276 {
277 crate::lock_file::Retry::new(None).run(|| File::open(path))
278 }
279 #[cfg(not(windows))]
280 {
281 File::open(path)
282 }
283}
284
285pub fn get(home: &Path, id: &str) -> Result<Job> {
287 let path = job_dir(home, id)?.join("job.json");
288 let file = match open_record(&path) {
289 Ok(file) => file,
290 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
291 let retained = root(home).join("results").join(id).join("job.json");
292 open_record(&retained)
293 .with_context(|| format!("open retained worker record {}", retained.display()))?
294 }
295 Err(error) => {
296 return Err(error).with_context(|| format!("open worker record {}", path.display()));
297 }
298 };
299 serde_json::from_reader(file).with_context(|| format!("decode worker record {id}"))
300}
301
302pub fn current_job(home: &Path) -> Result<Option<Job>> {
306 match std::env::var("MAGI_WORKER_ID") {
307 Ok(id) => Ok(Some(get(home, &id)?)),
308 Err(std::env::VarError::NotPresent) => Ok(None),
309 Err(error) => Err(error.into()),
310 }
311}
312
313fn next_sequence(home: &Path) -> Result<u64> {
314 let path = root(home).join("sequence.json");
315 let previous: u64 = match open_record(&path) {
316 Ok(file) => serde_json::from_reader(file)
317 .with_context(|| format!("decode worker sequence {}", path.display()))?,
318 Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0,
319 Err(error) => {
320 return Err(error).with_context(|| format!("open worker sequence {}", path.display()));
321 }
322 };
323 let sequence = previous
324 .checked_add(1)
325 .context("worker reservation sequence exhausted")?;
326 save(&path, &sequence)?;
327 Ok(sequence)
328}
329
330fn records(home: &Path) -> Result<Vec<Job>> {
331 let directory = root(home).join("jobs");
332 std::fs::create_dir_all(&directory)?;
333 let mut jobs = Vec::new();
334 let mut retired = false;
335 for entry in std::fs::read_dir(directory)? {
336 let entry = entry?;
337 if !entry.file_type()?.is_dir() {
338 continue;
339 }
340 let name = entry.file_name();
341 let Some(id) = name.to_str() else {
342 continue;
343 };
344 if id.starts_with(".new-") {
345 std::fs::remove_dir_all(entry.path())?;
348 continue;
349 }
350 if id.starts_with('.') {
351 continue;
352 }
353 for pending in std::fs::read_dir(entry.path())? {
354 let pending = pending?;
355 if pending.file_type()?.is_file()
356 && pending
357 .file_name()
358 .to_string_lossy()
359 .starts_with(".pending-")
360 {
361 std::fs::remove_file(pending.path())?;
362 }
363 }
364 let job = get(home, id).with_context(|| format!("read worker {id}"))?;
365 if matches!(job.status, Status::Starting | Status::Running) {
366 jobs.push(job);
367 } else {
368 retired |= retire(home, &job);
369 }
370 }
371 if retired {
372 if let Err(error) = prune_results(home) {
373 tracing::warn!(%error, "worker result retention deferred");
374 }
375 }
376 Ok(jobs)
377}
378
379fn terminal(home: &Path, job: &mut Job, status: Status, result: String) -> Result<()> {
380 job.status = status;
381 job.result = Some(result);
382 job.finished_at = Some(now());
383 save(&job_dir(home, &job.id)?.join("job.json"), job)
384 .with_context(|| format!("publish worker {} terminal status {status:?}", job.id))?;
385 clear_control_locked(home, &job.key)
389}
390
391fn retire(home: &Path, job: &Job) -> bool {
392 let attempt = || -> Result<bool> {
393 let dir = job_dir(home, &job.id)?;
394 let Some(lease) = try_lock(&dir.join("live.lock"))? else {
395 return Ok(false);
396 };
397 drop(lease);
398 archive(home, job)?;
399 Ok(true)
400 };
401 match attempt() {
406 Ok(retired) => retired,
407 Err(error) => {
408 tracing::warn!(
409 worker = %job.id,
410 error = %format_args!("{error:#}"),
411 "worker result retirement deferred"
412 );
413 false
414 }
415 }
416}
417
418fn archive(home: &Path, job: &Job) -> Result<()> {
419 let results = root(home).join("results");
420 std::fs::create_dir_all(&results)
421 .with_context(|| format!("create worker results directory {}", results.display()))?;
422 let from = job_dir(home, &job.id)?;
423 let to = results.join(&job.id);
424 std::fs::rename(&from, &to).with_context(|| {
425 format!(
426 "archive worker result {} -> {}",
427 from.display(),
428 to.display()
429 )
430 })?;
431 sync_dir(&root(home).join("jobs"))?;
432 sync_dir(&results)
433}
434
435fn reconcile(home: &Path, jobs: &mut [Job]) -> Result<()> {
438 let mut retired = false;
439 for job in jobs {
440 if !matches!(job.status, Status::Starting | Status::Running) {
441 continue;
442 }
443 let lease = try_lock(&job_dir(home, &job.id)?.join("live.lock"))?;
444 if lease.is_none() {
445 continue;
446 }
447 if job.status == Status::Starting && now().saturating_sub(job.created_at) < STARTUP_SECONDS
448 {
449 continue;
450 }
451 let reason = if job.status == Status::Starting {
452 "worker startup reservation expired before acknowledgement"
453 } else {
454 "worker exited without recording a result"
455 };
456 terminal(home, job, Status::Failed, reason.to_owned())?;
457 drop(lease);
458 retired |= retire(home, job);
459 }
460 if retired {
461 if let Err(error) = prune_results(home) {
462 tracing::warn!(%error, "worker result retention deferred");
463 }
464 }
465 Ok(())
466}
467
468pub fn active(home: &Path) -> Result<Vec<Job>> {
471 let _store = store_lock(home)?;
472 let mut jobs = records(home)?;
473 reconcile(home, &mut jobs)?;
474 Ok(jobs
475 .into_iter()
476 .filter(|job| matches!(job.status, Status::Starting | Status::Running))
477 .collect())
478}
479
480pub fn observe_active(home: &Path) -> Result<Vec<Job>> {
485 let entries = match std::fs::read_dir(root(home).join("jobs")) {
486 Ok(entries) => entries,
487 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
488 Err(error) => return Err(error.into()),
489 };
490 let mut active = Vec::new();
491 for entry in entries {
492 let entry = entry?;
493 if !entry.file_type()?.is_dir() {
494 continue;
495 }
496 let name = entry.file_name();
497 let id = name.to_str().context("invalid worker reservation name")?;
498 if id.starts_with('.') {
499 continue;
500 }
501 let job = get(home, id)?;
502 match job.status {
503 Status::Starting => active.push(job),
504 Status::Running => {
505 let file = match File::open(entry.path().join("live.lock")) {
506 Ok(file) => file,
507 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
508 active.push(job);
509 continue;
510 }
511 Err(error) => return Err(error.into()),
512 };
513 match crate::pr_head::try_lock_file(&file) {
514 Ok(()) => drop(Lease { _file: file }),
515 Err(error)
516 if error.kind() == std::io::ErrorKind::WouldBlock
517 || cfg!(windows) && error.raw_os_error() == Some(33) =>
518 {
519 active.push(job);
520 }
521 Err(error) => return Err(error.into()),
522 }
523 }
524 Status::Finished | Status::Failed => {}
525 }
526 }
527 Ok(active)
528}
529
530fn control_path(home: &Path, key: &str) -> PathBuf {
531 root(home)
532 .join("controls")
533 .join(format!("{}.json", digest(key.as_bytes())))
534}
535
536pub fn request(home: &Path, key: &str, control: Control) -> Result<()> {
538 let _store = store_lock(home)?;
539 save(&control_path(home, key), &control)
540}
541
542pub fn control(home: &Path, key: &str) -> Result<Option<Control>> {
544 let path = control_path(home, key);
545 match open_record(&path) {
546 Ok(file) => serde_json::from_reader(file)
547 .map(Some)
548 .with_context(|| format!("decode worker control {}", path.display())),
549 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
550 Err(e) => Err(e).with_context(|| format!("open worker control {}", path.display())),
551 }
552}
553
554fn clear_control_locked(home: &Path, key: &str) -> Result<()> {
555 let path = control_path(home, key);
556 match std::fs::remove_file(&path) {
557 Ok(()) => sync_dir(&root(home).join("controls")),
558 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
559 Err(e) => Err(e).with_context(|| format!("remove worker control {}", path.display())),
560 }
561}
562
563pub fn clear_control(home: &Path, key: &str) -> Result<()> {
565 let _store = store_lock(home)?;
566 clear_control_locked(home, key)
567}
568
569pub fn executable(home: &Path, generation: &str) -> Result<PathBuf> {
571 if generation.len() != 64 || !generation.bytes().all(|b| b.is_ascii_hexdigit()) {
572 bail!("invalid worker generation");
573 }
574 let path = root(home)
575 .join("generations")
576 .join(generation)
577 .join(if cfg!(windows) { "magi.exe" } else { "magi" });
578 if !path.is_file() {
579 bail!("worker generation {generation} is unavailable");
580 }
581 Ok(path)
582}
583
584pub fn helper_executable(home: &Path) -> Result<PathBuf> {
587 if let Some(generation) = std::env::var_os("MAGI_WORKER_GENERATION") {
588 return executable(
589 home,
590 generation
591 .to_str()
592 .context("invalid generation environment")?,
593 );
594 }
595 Ok(pin_current(home)?.executable)
596}
597
598pub fn remember_current_executable() -> Result<()> {
602 if INSTALLED_EXECUTABLE.get().is_some() {
603 return Ok(());
604 }
605 let path = std::env::current_exe()?.canonicalize()?;
606 let _ = INSTALLED_EXECUTABLE.set(path);
607 Ok(())
608}
609
610static INSTALLED_EXECUTABLE: OnceLock<PathBuf> = OnceLock::new();
611
612pub fn pin_current(home: &Path) -> Result<Generation> {
615 let source = match std::env::var_os("MAGI_WORKER_EXE") {
616 Some(path) => PathBuf::from(path),
617 None => match INSTALLED_EXECUTABLE.get() {
618 Some(path) => path.clone(),
619 None => std::env::current_exe()?,
620 },
621 };
622 let source = source.canonicalize().context("resolve worker executable")?;
623 if let Some(directory) = source.parent() {
626 if directory.parent() == Some(root(home).join("generations").as_path()) {
627 if let Some(id) = directory.file_name().and_then(|name| name.to_str()) {
628 if id.len() == 64
629 && id.bytes().all(|b| b.is_ascii_hexdigit())
630 && executable(home, id)?.canonicalize()? == source
631 {
632 return Ok(Generation {
633 id: id.to_owned(),
634 executable: source,
635 });
636 }
637 }
638 }
639 }
640 let stamp = source_stamp(&source)?;
641 let mut cache = PIN_CACHE
642 .lock()
643 .map_err(|_| anyhow::anyhow!("worker generation cache poisoned"))?;
644 if let Some(entry) = cache.iter().find(|entry| {
645 entry.home == home
646 && entry.source == source
647 && entry.stamp == stamp
648 && entry.generation.executable.is_file()
649 }) {
650 return Ok(entry.generation.clone());
651 }
652 let generation = pin_source(home, &source)?;
653 if source_stamp(&source)? != stamp {
654 bail!("worker executable changed while being pinned; retry");
655 }
656 if cache.len() >= 8 {
657 cache.remove(0);
658 }
659 cache.push(CachedGeneration {
660 home: home.to_path_buf(),
661 source,
662 stamp,
663 generation: generation.clone(),
664 });
665 Ok(generation)
666}
667
668type SourceStamp = (u64, Option<SystemTime>, Option<SystemTime>, u64, u64);
669struct CachedGeneration {
670 home: PathBuf,
671 source: PathBuf,
672 stamp: SourceStamp,
673 generation: Generation,
674}
675static PIN_CACHE: LazyLock<Mutex<Vec<CachedGeneration>>> = LazyLock::new(|| Mutex::new(Vec::new()));
676
677fn source_stamp(path: &Path) -> Result<SourceStamp> {
678 let metadata = std::fs::metadata(path)?;
679 #[cfg(unix)]
680 let identity = {
681 use std::os::unix::fs::MetadataExt;
682 (metadata.dev(), metadata.ino())
683 };
684 #[cfg(not(unix))]
685 let identity = (0, 0);
686 Ok((
687 metadata.len(),
688 metadata.modified().ok(),
689 metadata.created().ok(),
690 identity.0,
691 identity.1,
692 ))
693}
694
695fn pin_source(home: &Path, source: &Path) -> Result<Generation> {
696 let generations = root(home).join("generations");
697 std::fs::create_dir_all(&generations)?;
698 let staging = generations.join(format!(".new-{}", nonce()));
699 let mut staging_lease = {
700 let _store = store_lock(home)?;
701 for entry in std::fs::read_dir(&generations)? {
703 let entry = entry?;
704 if entry.file_type()?.is_dir()
705 && entry.file_name().to_string_lossy().starts_with(".new-")
706 {
707 if let Some(lease) = try_lock(&entry.path().join("pin.lock"))? {
708 drop(lease);
709 remove_staging(&entry.path())?;
710 }
711 }
712 }
713 std::fs::create_dir(&staging)?;
714 Some(try_lock(&staging.join("pin.lock"))?.context("generation staging lease unavailable")?)
715 };
716 let temp = staging.join(if cfg!(windows) { "magi.exe" } else { "magi" });
717 let result = (|| -> Result<Generation> {
718 let mut input = File::open(source)?;
719 let mut output = OpenOptions::new()
720 .create_new(true)
721 .write(true)
722 .open(&temp)?;
723 let mut hash = Sha256::new();
724 let mut buffer = [0u8; 65536];
725 loop {
726 let n = input.read(&mut buffer)?;
727 if n == 0 {
728 break;
729 }
730 hash.update(&buffer[..n]);
731 output.write_all(&buffer[..n])?;
732 }
733 let id = hex_digest(hash);
734 let mut permissions = input.metadata()?.permissions();
735 permissions.set_readonly(true);
736 output.set_permissions(permissions)?;
737 output.sync_all()?;
738 drop(output);
739 sync_dir(&staging)?;
740 let target = generations.join(&id);
741 let _store = store_lock(home)?;
743 drop(staging_lease.take());
744 std::fs::remove_file(staging.join("pin.lock"))?;
745 if target.exists() {
746 let existing = target.join(temp.file_name().context("binary filename")?);
747 if hash_file(&existing)? != id {
748 bail!("immutable worker generation is corrupt: {id}");
749 }
750 } else {
751 std::fs::rename(&staging, &target)?;
752 sync_dir(&generations)?;
753 }
754 Ok(Generation {
755 executable: target.join(temp.file_name().context("binary filename")?),
756 id,
757 })
758 })();
759 let _store = store_lock(home)?;
760 drop(staging_lease);
761 if staging.exists() {
762 remove_staging(&staging)?;
763 }
764 result
765}
766
767fn remove_staging(staging: &Path) -> Result<()> {
768 #[cfg(windows)]
769 {
770 use std::os::windows::{ffi::OsStrExt, fs::MetadataExt};
771 #[link(name = "kernel32")]
772 unsafe extern "system" {
773 fn SetFileAttributesW(path: *const u16, attributes: u32) -> i32;
774 }
775 const READONLY: u32 = 0x1;
776 for entry in std::fs::read_dir(staging)? {
777 let entry = entry?;
778 let attributes = entry.metadata()?.file_attributes();
779 if attributes & READONLY != 0 {
780 let path: Vec<u16> = entry.path().as_os_str().encode_wide().chain([0]).collect();
781 if unsafe { SetFileAttributesW(path.as_ptr(), attributes & !READONLY) } == 0 {
784 return Err(std::io::Error::last_os_error())
785 .context("clear staging readonly attribute");
786 }
787 }
788 }
789 }
790 Ok(std::fs::remove_dir_all(staging)?)
791}
792
793fn hash_file(path: &Path) -> Result<String> {
794 let mut file = File::open(path)?;
795 let mut hash = Sha256::new();
796 let mut buffer = [0u8; 65536];
797 loop {
798 let n = file.read(&mut buffer)?;
799 if n == 0 {
800 break;
801 }
802 hash.update(&buffer[..n]);
803 }
804 Ok(hex_digest(hash))
805}
806
807fn reserve(
808 home: &Path,
809 generation: &Generation,
810 kind: &str,
811 key: &str,
812 payload: serde_json::Value,
813) -> Result<(Job, bool)> {
814 if kind.is_empty() || key.is_empty() {
815 bail!("worker kind and operation key must be nonempty");
816 }
817 let _store = store_lock(home)?;
818 let mut jobs = records(home)?;
819 reconcile(home, &mut jobs)?;
820 if let Some(job) = jobs
821 .iter()
822 .find(|job| job.key == key && matches!(job.status, Status::Starting | Status::Running))
823 {
824 if job.kind != kind || job.payload != payload {
825 bail!("worker operation key {key} is already reserved for a different request");
826 }
827 return Ok((job.clone(), false));
828 }
829 let id = nonce();
830 let job = Job {
831 id: id.clone(),
832 sequence: next_sequence(home)?,
833 kind: kind.to_owned(),
834 key: key.to_owned(),
835 payload,
836 generation: generation.id.clone(),
837 version: None,
838 status: Status::Starting,
839 result: None,
840 pid: None,
841 created_at: now(),
842 finished_at: None,
843 };
844 let jobs_dir = root(home).join("jobs");
845 let staging = jobs_dir.join(format!(".new-{id}"));
846 std::fs::create_dir(&staging)
847 .with_context(|| format!("create worker reservation staging {}", staging.display()))?;
848 save(&staging.join("job.json"), &job)?;
849 clear_control_locked(home, key)?;
850 std::fs::rename(&staging, job_dir(home, &id)?)
851 .with_context(|| format!("publish worker {id} reservation from {}", staging.display()))?;
852 sync_dir(&jobs_dir)?;
853 Ok((job, true))
854}
855
856fn prune_results(home: &Path) -> Result<()> {
857 let results = root(home).join("results");
858 std::fs::create_dir_all(&results)?;
859 let mut entries = Vec::new();
860 for entry in std::fs::read_dir(&results)? {
861 let entry = entry?;
862 if entry.file_type()?.is_dir() {
863 entries.push((entry.metadata()?.modified()?, entry.path()));
866 }
867 }
868 entries.sort_unstable_by_key(|(modified, _)| std::cmp::Reverse(*modified));
869 for (_, dir) in entries.into_iter().skip(RETAIN_FINISHED) {
870 if let Some(lease) = try_lock(&dir.join("live.lock"))? {
871 drop(lease);
872 std::fs::remove_dir_all(dir)?;
873 }
874 }
875 sync_dir(&results)
876}
877
878fn detached_command(home: &Path, job: &Job, generation: &Generation) -> Result<Command> {
879 let log_path = job_dir(home, &job.id)?.join("worker.log");
883 let log = OpenOptions::new()
884 .create(true)
885 .append(true)
886 .open(&log_path)
887 .with_context(|| format!("open worker startup log {}", log_path.display()))?;
888 let stderr = log
889 .try_clone()
890 .with_context(|| format!("clone worker startup log {}", log_path.display()))?;
891 let mut command = Command::new(&generation.executable);
892 command
893 .arg("__worker")
894 .arg("--home")
895 .arg(home)
896 .arg(&job.id)
897 .env("MAGI_HOME", home)
898 .env("MAGI_WORKER_HOME", home)
899 .env("MAGI_WORKER_ID", &job.id)
900 .env("MAGI_WORKER_GENERATION", &generation.id)
901 .env("MAGI_WORKER_EXE", &generation.executable)
902 .env_remove("MAGI_RUN")
903 .env_remove("MAGI_NODE")
904 .env_remove("MAGI_SEAT")
905 .env_remove("MAGI_TURN_SCOPE")
906 .stdin(Stdio::null())
907 .stdout(Stdio::from(log))
908 .stderr(Stdio::from(stderr));
909 let mut paths = vec![
910 generation
911 .executable
912 .parent()
913 .context("generation directory")?
914 .to_path_buf(),
915 ];
916 if let Some(path) = std::env::var_os("PATH") {
917 paths.extend(std::env::split_paths(&path));
918 }
919 command.env("PATH", std::env::join_paths(paths)?);
920 #[cfg(unix)]
921 {
922 use std::os::unix::process::CommandExt;
923 unsafe {
924 command.pre_exec(|| {
925 if libc::setsid() < 0 {
926 return Err(std::io::Error::last_os_error());
927 }
928 libc::signal(libc::SIGINT, libc::SIG_DFL);
931 libc::signal(libc::SIGTERM, libc::SIG_DFL);
932 Ok(())
933 });
934 }
935 }
936 #[cfg(windows)]
937 {
938 use std::os::windows::process::CommandExt;
939 command.creation_flags(
940 crate::chat_watch::windows::detached_flags()
941 .context("prepare independent Windows worker")?,
942 );
943 }
944 Ok(command)
945}
946
947pub async fn spawn(home: &Path, kind: &str, key: &str, payload: serde_json::Value) -> Result<Job> {
952 std::fs::create_dir_all(home).map_err(not_started_error)?;
953 let home = home
954 .canonicalize()
955 .context("resolve worker home")
956 .map_err(not_started_error)?;
957 let pin_home = home.clone();
958 let generation = tokio::task::spawn_blocking(move || pin_current(&pin_home))
959 .await
960 .map_err(not_started_error)?
961 .map_err(not_started_error)?;
962 let (job, fresh) = reserve(&home, &generation, kind, key, payload)?;
963 if fresh {
964 match detached_command(&home, &job, &generation)
965 .and_then(|mut command| Ok(command.spawn()?))
966 {
967 Ok(mut child) => {
968 std::thread::spawn(move || {
971 let _ = child.wait();
972 });
973 }
974 Err(error) => {
975 let _store = store_lock(&home)?;
976 let mut job = get(&home, &job.id)?;
977 if job.status == Status::Starting {
978 if let Some(_lease) = try_lock(&job_dir(&home, &job.id)?.join("live.lock"))? {
979 terminal(
980 &home,
981 &mut job,
982 Status::Failed,
983 format!("worker spawn failed: {error}"),
984 )?;
985 return Err(not_started_error(error));
986 }
987 }
988 return Err(error);
989 }
990 }
991 }
992 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
993 loop {
994 let current = get(&home, &job.id)?;
995 match current.status {
996 Status::Running | Status::Finished => return Ok(current),
997 Status::Failed => bail!(
998 "worker {} failed: {}",
999 current.id,
1000 current.result.as_deref().unwrap_or("unknown failure")
1001 ),
1002 Status::Starting => {}
1003 }
1004 if tokio::time::Instant::now() >= deadline {
1005 bail!(
1006 "worker {} startup is unacknowledged; its reservation remains occupied; inspect or retry the same operation key",
1007 job.id
1008 );
1009 }
1010 tokio::time::sleep(Duration::from_millis(20)).await;
1011 }
1012}
1013
1014pub async fn run(home: &Path, id: &str) -> Result<()> {
1018 let dir = job_dir(home, id)?;
1019 let (job, _lease) = {
1020 let _store = store_lock(home)?;
1021 let mut job = get(home, id)?;
1022 if job.status != Status::Starting {
1023 bail!("worker reservation is no longer startable");
1024 }
1025 let lease =
1026 try_lock(&dir.join("live.lock"))?.context("worker execution lease is already held")?;
1027 let current = std::env::current_exe()
1028 .context("locate running worker executable")?
1029 .canonicalize()
1030 .context("resolve running worker executable")?;
1031 let expected = executable(home, &job.generation)?
1032 .canonicalize()
1033 .with_context(|| {
1034 format!("resolve worker {id} reserved generation {}", job.generation)
1035 })?;
1036 if current != expected {
1037 bail!("worker must execute its reserved immutable generation");
1038 }
1039 job.pid = Some(std::process::id());
1040 job.version = Some(env!("CARGO_PKG_VERSION").to_owned());
1041 job.status = Status::Running;
1042 save(&dir.join("job.json"), &job)
1043 .with_context(|| format!("acknowledge worker {id} startup"))?;
1044 (job, lease)
1045 };
1046 let result = match job.kind.as_str() {
1047 "task" => crate::daemon::run_task_worker(home, job.payload.clone()).await,
1048 "scheduler" => crate::daemon::run_scheduler_worker(home, job.payload.clone()).await,
1049 "chat" => crate::consult::run_turn_worker(home, job.payload.clone()).await,
1050 "service" => crate::consult::run_service_worker(home, job.payload.clone()).await,
1051 "daemon-service" => crate::daemon::run_service_worker(home, job.payload.clone()).await,
1052 "deputy" => crate::deputy::run_worker(home, job.payload.clone()).await,
1053 kind => Err(anyhow::anyhow!("unknown worker kind {kind}")),
1054 };
1055 let _store = store_lock(home)?;
1056 let mut current = get(home, id)?;
1057 match &result {
1058 Ok(()) => terminal(home, &mut current, Status::Finished, "completed".to_owned())?,
1059 Err(error) => terminal(home, &mut current, Status::Failed, format!("{error:#}"))?,
1060 }
1061 result
1062}
1063
1064fn hex_digest(hash: Sha256) -> String {
1065 format!("{:x}", hash.finalize())
1066}
1067fn digest(bytes: &[u8]) -> String {
1068 let mut hash = Sha256::new();
1069 hash.update(bytes);
1070 hex_digest(hash)
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075 use super::*;
1076
1077 #[test]
1078 fn observing_an_unused_store_requires_no_writes() {
1079 let home = tempfile::tempdir().unwrap();
1080 assert!(observe_active(home.path()).unwrap().is_empty());
1081 assert!(!home.path().join("workers").exists());
1082 }
1083
1084 #[test]
1085 fn observing_execution_probes_the_lease_without_rewriting_history() {
1086 let home = tempfile::tempdir().unwrap();
1087 let mut job = reservation(home.path(), "task:observer");
1088 let directory = job_dir(home.path(), &job.id).unwrap();
1089 let live_path = directory.join("live.lock");
1090 let lease = try_lock(&live_path).unwrap().unwrap();
1091 job.status = Status::Running;
1092 save(&directory.join("job.json"), &job).unwrap();
1093 #[cfg(unix)]
1094 {
1095 use std::os::unix::fs::PermissionsExt as _;
1096 std::fs::set_permissions(&live_path, std::fs::Permissions::from_mode(0o444)).unwrap();
1097 }
1098 let before = std::fs::read(directory.join("job.json")).unwrap();
1099 let observed = observe_active(home.path()).unwrap();
1100 assert_eq!(observed.len(), 1);
1101 assert_eq!(observed[0].id, job.id);
1102 drop(lease);
1103 assert!(observe_active(home.path()).unwrap().is_empty());
1104 assert_eq!(std::fs::read(directory.join("job.json")).unwrap(), before);
1105 assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Running);
1106 }
1107 fn generation() -> Generation {
1108 Generation {
1109 id: "a".repeat(64),
1110 executable: PathBuf::from("unused"),
1111 }
1112 }
1113 fn reservation(home: &Path, key: &str) -> Job {
1114 reserve(
1115 home,
1116 &generation(),
1117 "task",
1118 key,
1119 serde_json::json!({"task":"example"}),
1120 )
1121 .unwrap()
1122 .0
1123 }
1124
1125 #[test]
1126 fn reservation_deduplicates_identical_keys_and_rejects_collision() {
1127 let home = tempfile::tempdir().unwrap();
1128 let first = reservation(home.path(), "task:one");
1129 assert_eq!(first.id, reservation(home.path(), "task:one").id);
1130 assert!(
1131 reserve(
1132 home.path(),
1133 &generation(),
1134 "chat",
1135 "task:one",
1136 serde_json::Value::Null
1137 )
1138 .is_err()
1139 );
1140 assert_ne!(first.id, reservation(home.path(), "task:two").id);
1141 }
1142
1143 #[test]
1144 fn reservation_order_is_durable_and_corruption_never_resets_it() {
1145 let home = tempfile::tempdir().unwrap();
1146 let first = reservation(home.path(), "chat:first");
1147 assert_eq!(
1148 first.sequence,
1149 reservation(home.path(), "chat:first").sequence
1150 );
1151 let second = reservation(home.path(), "chat:second");
1152 assert!(second.sequence > first.sequence);
1153 let encoded = serde_json::to_vec(&second).unwrap();
1154 assert_eq!(
1155 serde_json::from_slice::<Job>(&encoded).unwrap().sequence,
1156 second.sequence
1157 );
1158 let mut legacy = serde_json::to_value(&first).unwrap();
1159 legacy.as_object_mut().unwrap().remove("sequence");
1160 assert_eq!(serde_json::from_value::<Job>(legacy).unwrap().sequence, 0);
1161 std::fs::write(root(home.path()).join("sequence.json"), b"broken").unwrap();
1162 assert!(
1163 reserve(
1164 home.path(),
1165 &generation(),
1166 "chat",
1167 "chat:third",
1168 serde_json::Value::Null
1169 )
1170 .is_err()
1171 );
1172 }
1173
1174 #[tokio::test]
1175 async fn pre_reservation_failure_is_proven_without_history_absence_guesses() {
1176 let home = tempfile::tempdir().unwrap();
1177 let blocked = home.path().join("not-a-directory");
1178 std::fs::write(&blocked, b"file").unwrap();
1179 let error = spawn(&blocked, "chat", "fresh:chat", serde_json::Value::Null)
1180 .await
1181 .unwrap_err();
1182 assert!(not_started(&error));
1183 assert!(not_started(&error.context("publish recorded turn")));
1184 assert!(!not_started(&anyhow::anyhow!(
1185 "startup acknowledgement timed out"
1186 )));
1187 assert!(!blocked.join("workers").exists());
1188 }
1189
1190 #[test]
1191 fn failed_os_spawn_is_fenced_and_proven_not_started() {
1192 let home = tempfile::tempdir().unwrap();
1193 let executable = home.path().join("invalid-executable");
1194 std::fs::write(&executable, b"not an executable binary").unwrap();
1195 let output = Command::new(std::env::current_exe().unwrap())
1196 .args([
1197 "--exact",
1198 "worker::tests::unstarted_spawn_helper",
1199 "--ignored",
1200 "--nocapture",
1201 ])
1202 .env("MAGI_WORKER_EXE", &executable)
1203 .env("MAGI_SPAWN_FAILURE_HOME", home.path())
1204 .output()
1205 .unwrap();
1206 assert!(
1207 output.status.success(),
1208 "{}\n{}",
1209 String::from_utf8_lossy(&output.stdout),
1210 String::from_utf8_lossy(&output.stderr)
1211 );
1212 }
1213
1214 #[tokio::test]
1215 #[ignore = "isolated environment for the failed OS spawn test"]
1216 async fn unstarted_spawn_helper() {
1217 let home = PathBuf::from(std::env::var_os("MAGI_SPAWN_FAILURE_HOME").unwrap());
1218 let error = spawn(&home, "chat", "fresh:failed-exec", serde_json::Value::Null)
1219 .await
1220 .unwrap_err();
1221 assert!(not_started(&error));
1222 let entry = std::fs::read_dir(root(&home).join("jobs"))
1223 .unwrap()
1224 .next()
1225 .unwrap()
1226 .unwrap();
1227 let id = entry.file_name().to_str().unwrap().to_owned();
1228 let job = get(&home, &id).unwrap();
1229 assert_eq!(job.status, Status::Failed);
1230 assert_eq!(job.pid, None);
1231 assert!(active(&home).unwrap().is_empty());
1232 assert_eq!(get(&home, &id).unwrap().status, Status::Failed);
1233 }
1234
1235 #[test]
1236 fn unresolved_startup_remains_reserved_and_live_lease_never_expires() {
1237 let home = tempfile::tempdir().unwrap();
1238 let mut job = reservation(home.path(), "task:one");
1239 assert_eq!(active(home.path()).unwrap().len(), 1);
1240 job.created_at = 0;
1241 save(
1242 &job_dir(home.path(), &job.id).unwrap().join("job.json"),
1243 &job,
1244 )
1245 .unwrap();
1246 let lease = try_lock(&job_dir(home.path(), &job.id).unwrap().join("live.lock"))
1247 .unwrap()
1248 .unwrap();
1249 assert_eq!(active(home.path()).unwrap().len(), 1);
1250 drop(lease);
1251 assert!(active(home.path()).unwrap().is_empty());
1252 assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Failed);
1253 }
1254
1255 #[tokio::test]
1256 async fn cancelled_startup_cannot_execute_late() {
1257 let home = tempfile::tempdir().unwrap();
1258 let mut job = reservation(home.path(), "task:one");
1259 job.created_at = 0;
1260 save(
1261 &job_dir(home.path(), &job.id).unwrap().join("job.json"),
1262 &job,
1263 )
1264 .unwrap();
1265 assert!(active(home.path()).unwrap().is_empty());
1266 assert!(
1267 run(home.path(), &job.id)
1268 .await
1269 .unwrap_err()
1270 .to_string()
1271 .contains("no longer startable")
1272 );
1273 }
1274
1275 #[test]
1276 fn running_without_lease_fails_regardless_of_pid() {
1277 let home = tempfile::tempdir().unwrap();
1278 let mut job = reservation(home.path(), "task:one");
1279 job.status = Status::Running;
1280 job.pid = Some(std::process::id());
1281 save(
1282 &job_dir(home.path(), &job.id).unwrap().join("job.json"),
1283 &job,
1284 )
1285 .unwrap();
1286 assert!(active(home.path()).unwrap().is_empty());
1287 assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Failed);
1288 }
1289
1290 #[test]
1291 fn controls_survive_reopen_and_reset_at_new_invocation() {
1292 let home = tempfile::tempdir().unwrap();
1293 request(home.path(), "task:one", Control::Park).unwrap();
1294 assert_eq!(
1295 control(home.path(), "task:one").unwrap(),
1296 Some(Control::Park)
1297 );
1298 reservation(home.path(), "task:one");
1299 assert_eq!(control(home.path(), "task:one").unwrap(), None);
1300 request(home.path(), "task:one", Control::Stop).unwrap();
1301 assert_eq!(
1302 control(home.path(), "task:one").unwrap(),
1303 Some(Control::Stop)
1304 );
1305 assert_eq!(control(home.path(), "task:other").unwrap(), None);
1306 }
1307
1308 #[test]
1309 fn singleton_locks_release_without_pid_cleanup() {
1310 let home = tempfile::tempdir().unwrap();
1311 let lease = try_lease(home.path(), "scheduler").unwrap().unwrap();
1312 assert!(try_lease(home.path(), "scheduler").unwrap().is_none());
1313 assert!(try_lease(home.path(), "other").unwrap().is_some());
1314 drop(lease);
1315 assert!(try_lease(home.path(), "scheduler").unwrap().is_some());
1316 }
1317
1318 #[test]
1319 fn immutable_generation_keeps_old_content_after_source_replacement() {
1320 let home = tempfile::tempdir().unwrap();
1321 let source = home.path().join("source");
1322 std::fs::write(&source, b"old executable").unwrap();
1323 let old = pin_source(home.path(), &source).unwrap();
1324 std::fs::write(&source, b"new executable").unwrap();
1325 let new = pin_source(home.path(), &source).unwrap();
1326 assert_ne!(old.id, new.id);
1327 assert_eq!(
1328 std::fs::read(executable(home.path(), &old.id).unwrap()).unwrap(),
1329 b"old executable"
1330 );
1331 assert_eq!(std::fs::read(new.executable).unwrap(), b"new executable");
1332 }
1333
1334 #[test]
1335 fn acknowledgement_records_own_version_and_handler_failure() {
1336 let home = tempfile::tempdir().unwrap();
1337 let generation = pin_source(home.path(), &std::env::current_exe().unwrap()).unwrap();
1338 let (job, _) = reserve(
1339 home.path(),
1340 &generation,
1341 "unknown-kind",
1342 "unknown:one",
1343 serde_json::Value::Null,
1344 )
1345 .unwrap();
1346 let invoke = || {
1347 let output = Command::new(&generation.executable)
1348 .args([
1349 "--exact",
1350 "worker::tests::pinned_entrypoint_helper",
1351 "--ignored",
1352 "--nocapture",
1353 ])
1354 .env("MAGI_WORKER_TEST_HOME", home.path())
1355 .env("MAGI_WORKER_TEST_ID", &job.id)
1356 .output()
1357 .unwrap();
1358 assert!(
1359 output.status.success(),
1360 "{}",
1361 String::from_utf8_lossy(&output.stderr)
1362 );
1363 };
1364 invoke();
1365 let failed = get(home.path(), &job.id).unwrap();
1366 assert_eq!(failed.status, Status::Failed);
1367 assert_eq!(failed.version.as_deref(), Some(env!("CARGO_PKG_VERSION")));
1368 assert!(failed.result.unwrap().contains("unknown worker kind"));
1369 assert!(active(home.path()).unwrap().is_empty());
1370 assert!(!job_dir(home.path(), &job.id).unwrap().exists());
1371 invoke();
1372 assert!(!job_dir(home.path(), &job.id).unwrap().exists());
1373 }
1374
1375 #[tokio::test]
1376 #[ignore = "subprocess entrypoint for the immutable-generation startup test"]
1377 async fn pinned_entrypoint_helper() {
1378 let home = PathBuf::from(std::env::var_os("MAGI_WORKER_TEST_HOME").unwrap());
1379 let id = std::env::var("MAGI_WORKER_TEST_ID").unwrap();
1380 assert!(run(&home, &id).await.is_err());
1381 }
1382
1383 #[test]
1384 fn unfinished_staging_is_reclaimed_without_removing_generations() {
1385 let home = tempfile::tempdir().unwrap();
1386 let old = root(home.path()).join("generations").join(".new-abandoned");
1387 std::fs::create_dir_all(&old).unwrap();
1388 let partial = old.join("partial");
1389 std::fs::write(&partial, b"interrupted copy").unwrap();
1390 #[cfg(windows)]
1391 {
1392 let mut permissions = std::fs::metadata(&partial).unwrap().permissions();
1393 permissions.set_readonly(true);
1394 std::fs::set_permissions(&partial, permissions).unwrap();
1395 }
1396 let source = home.path().join("source");
1397 std::fs::write(&source, b"complete executable").unwrap();
1398 let generation = pin_source(home.path(), &source).unwrap();
1399 assert!(!old.exists());
1400 assert!(generation.executable.is_file());
1401 }
1402
1403 #[tokio::test]
1404 async fn unrelated_executable_cannot_acknowledge_or_consume_reservation() {
1405 let home = tempfile::tempdir().unwrap();
1406 let source = home.path().join("other-binary");
1407 std::fs::write(&source, b"different executable of the same Cargo version").unwrap();
1408 let generation = pin_source(home.path(), &source).unwrap();
1409 let (job, _) = reserve(
1410 home.path(),
1411 &generation,
1412 "task",
1413 "task:one",
1414 serde_json::Value::Null,
1415 )
1416 .unwrap();
1417 assert!(
1418 run(home.path(), &job.id)
1419 .await
1420 .unwrap_err()
1421 .to_string()
1422 .contains("immutable generation")
1423 );
1424 assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Starting);
1425 assert_eq!(active(home.path()).unwrap().len(), 1);
1426 }
1427
1428 #[test]
1429 fn abandoned_record_staging_is_bounded_and_never_executes() {
1430 let home = tempfile::tempdir().unwrap();
1431 let staging = root(home.path()).join("jobs").join(".new-interrupted");
1432 std::fs::create_dir_all(&staging).unwrap();
1433 std::fs::write(staging.join("job.json"), b"partial json").unwrap();
1434 assert!(active(home.path()).unwrap().is_empty());
1435 assert!(!staging.exists());
1436 }
1437
1438 #[cfg(unix)]
1439 #[test]
1440 fn detached_startup_preserves_stdout_and_stderr_in_record_log() {
1441 use std::os::unix::fs::PermissionsExt;
1442 let home = tempfile::tempdir().unwrap();
1443 let source = home.path().join("startup-diagnostic");
1444 std::fs::write(
1445 &source,
1446 b"#!/bin/sh\nprintf 'startup stdout\\n'\nprintf 'startup stderr\\n' >&2\nexit 7\n",
1447 )
1448 .unwrap();
1449 std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o755)).unwrap();
1450 let generation = pin_source(home.path(), &source).unwrap();
1451 let (mut job, _) = reserve(
1452 home.path(),
1453 &generation,
1454 "task",
1455 "task:log",
1456 serde_json::Value::Null,
1457 )
1458 .unwrap();
1459 let mut command = detached_command(home.path(), &job, &generation).unwrap();
1460 for key in ["MAGI_RUN", "MAGI_NODE", "MAGI_SEAT", "MAGI_TURN_SCOPE"] {
1461 assert!(
1462 command
1463 .get_envs()
1464 .any(|(name, value)| name == key && value.is_none())
1465 );
1466 }
1467 assert_eq!(command.spawn().unwrap().wait().unwrap().code(), Some(7));
1468 let log = job_dir(home.path(), &job.id).unwrap().join("worker.log");
1469 let output = std::fs::read_to_string(log).unwrap();
1470 assert!(output.contains("startup stdout"));
1471 assert!(output.contains("startup stderr"));
1472 terminal(
1473 home.path(),
1474 &mut job,
1475 Status::Failed,
1476 "startup failed".into(),
1477 )
1478 .unwrap();
1479 assert!(active(home.path()).unwrap().is_empty());
1480 assert!(
1481 root(home.path())
1482 .join("results")
1483 .join(&job.id)
1484 .join("worker.log")
1485 .is_file()
1486 );
1487 }
1488
1489 #[cfg(windows)]
1490 #[test]
1491 fn record_publication_survives_readers_and_short_delete_sharing_handoffs() {
1492 use std::os::windows::fs::OpenOptionsExt as _;
1493
1494 let home = tempfile::tempdir().unwrap();
1495 let mut job = reservation(home.path(), "task:metadata-handoff");
1496 let directory = job_dir(home.path(), &job.id).unwrap();
1497 let path = directory.join("job.json");
1498 let lease = try_lock(&directory.join("live.lock")).unwrap().unwrap();
1499 let snapshot = File::open(&path).unwrap();
1500 let reader = OpenOptions::new()
1502 .read(true)
1503 .share_mode(1 | 2) .open(&path)
1505 .unwrap();
1506 std::thread::scope(|scope| {
1507 scope.spawn(move || {
1508 std::thread::sleep(Duration::from_millis(15));
1509 drop(reader);
1510 });
1511 job.status = Status::Running;
1512 save(&path, &job).unwrap();
1513 });
1514 let original: Job = serde_json::from_reader(snapshot).unwrap();
1515 assert_eq!(original.status, Status::Starting);
1516 assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Running);
1517 let running_snapshot = File::open(&path).unwrap();
1518 terminal(home.path(), &mut job, Status::Finished, "completed".into()).unwrap();
1519 assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Finished);
1520 let previous: Job = serde_json::from_reader(running_snapshot).unwrap();
1521 assert_eq!(previous.status, Status::Running);
1522 drop(lease);
1523 assert!(active(home.path()).unwrap().is_empty());
1524 }
1525
1526 #[cfg(windows)]
1527 #[test]
1528 fn denied_record_publication_preserves_reservation_and_original_os_error() {
1529 use std::os::windows::fs::OpenOptionsExt as _;
1530
1531 let home = tempfile::tempdir().unwrap();
1532 let job = reservation(home.path(), "task:metadata-denied");
1533 let directory = job_dir(home.path(), &job.id).unwrap();
1534 let path = directory.join("job.json");
1535 let reader = OpenOptions::new()
1536 .read(true)
1537 .share_mode(1 | 2) .open(&path)
1539 .unwrap();
1540 let mut acknowledged = job.clone();
1541 acknowledged.status = Status::Running;
1542 let error = save(&path, &acknowledged).unwrap_err();
1543 assert!(format!("{error:#}").contains("publish worker record"));
1544 let io = error.downcast_ref::<std::io::Error>().unwrap();
1545 assert!(matches!(io.raw_os_error(), Some(5 | 32)));
1546 assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Starting);
1547 assert_eq!(reservation(home.path(), &job.key).id, job.id);
1548 assert!(std::fs::read_dir(&directory).unwrap().all(|entry| {
1549 !entry
1550 .unwrap()
1551 .file_name()
1552 .to_string_lossy()
1553 .starts_with(".pending-")
1554 }));
1555 drop(reader);
1556 save(&path, &acknowledged).unwrap();
1557 assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Running);
1558 }
1559
1560 #[test]
1561 fn terminal_result_survives_open_execution_and_output_handles() {
1562 let home = tempfile::tempdir().unwrap();
1563 let mut job = reservation(home.path(), "task:open-handles");
1564 let directory = job_dir(home.path(), &job.id).unwrap();
1565 let lease = try_lock(&directory.join("live.lock")).unwrap().unwrap();
1566 let mut log = OpenOptions::new()
1567 .create(true)
1568 .append(true)
1569 .open(directory.join("worker.log"))
1570 .unwrap();
1571 writeln!(log, "before completion").unwrap();
1572 terminal(home.path(), &mut job, Status::Finished, "completed".into()).unwrap();
1573 assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Finished);
1574 assert!(active(home.path()).unwrap().is_empty());
1575 assert!(
1576 directory.exists(),
1577 "a held execution lease must prevent retirement"
1578 );
1579 drop(lease);
1580 assert!(active(home.path()).unwrap().is_empty());
1583 assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Finished);
1584 writeln!(log, "after completion").unwrap();
1585 drop(log);
1586 assert!(active(home.path()).unwrap().is_empty());
1587 assert!(
1588 root(home.path())
1589 .join("results")
1590 .join(&job.id)
1591 .join("worker.log")
1592 .is_file()
1593 );
1594 assert!(!directory.exists());
1595 }
1596
1597 #[test]
1598 fn terminal_records_are_bounded_without_pruning_live_reservations() {
1599 let home = tempfile::tempdir().unwrap();
1600 for i in 0..RETAIN_FINISHED + 3 {
1601 let mut job = reservation(home.path(), &format!("task:{i}"));
1602 terminal(home.path(), &mut job, Status::Finished, "done".into()).unwrap();
1603 }
1604 let live = reservation(home.path(), "task:live");
1605 let jobs = records(home.path()).unwrap();
1606 assert_eq!(jobs.len(), 1);
1607 assert_eq!(
1608 std::fs::read_dir(root(home.path()).join("results"))
1609 .unwrap()
1610 .count(),
1611 RETAIN_FINISHED
1612 );
1613 assert!(jobs.iter().any(|job| job.id == live.id));
1614 }
1615}