@@ -15,10 +15,12 @@ use crate::ir::{
1515} ;
1616use std:: collections:: HashMap ;
1717use std:: ffi:: c_void;
18+ use std:: io:: ErrorKind ;
1819use std:: path:: { Path , PathBuf } ;
19- use std:: process:: Command ;
20+ use std:: process:: { Command , Stdio } ;
2021use std:: sync:: mpsc:: Sender ;
2122use std:: sync:: { Arc , Mutex , OnceLock } ;
23+ use std:: time:: Duration ;
2224use veryl_analyzer:: ir:: Op ;
2325use veryl_analyzer:: value:: Value ;
2426
@@ -2862,7 +2864,22 @@ fn compile_source_in(cache_dir: &Path, src: &str) -> Result<EmittedModule, Strin
28622864 ] ) ;
28632865 let so_path = cache_dir. join ( format ! ( "veryl_aot_{hash}.so" ) ) ;
28642866
2865- if !so_path. exists ( ) {
2867+ // One compiler per artifact hash, across processes and pool workers alike.
2868+ // `Published` means someone else landed it while we waited; the second
2869+ // `exists` check closes the window between the first one and the lock.
2870+ let ticket = if so_path. exists ( ) {
2871+ CompileTicket :: Published
2872+ } else {
2873+ acquire_compile_lock ( cache_dir, & hash, & so_path)
2874+ } ;
2875+ // Only the unix path hands the lock to the shell; elsewhere `Drop` alone
2876+ // releases it.
2877+ #[ cfg( unix) ]
2878+ let lock_path = match & ticket {
2879+ CompileTicket :: Owned ( l) => Some ( l. path . clone ( ) ) ,
2880+ CompileTicket :: Published | CompileTicket :: Unlocked => None ,
2881+ } ;
2882+ if !matches ! ( ticket, CompileTicket :: Published ) && !so_path. exists ( ) {
28662883 // Identical sources hash to the same `so_path`, so a `cc -o so_path`
28672884 // from one thread can be dlopened half-written by another. Compile to a
28682885 // unique temp, then `rename`/`mv` (atomic within the dir) to publish.
@@ -2888,9 +2905,14 @@ fn compile_source_in(cache_dir: &Path, src: &str) -> Result<EmittedModule, Strin
28882905 #[ cfg( unix) ]
28892906 let out = {
28902907 let mut cmd = Command :: new ( "/bin/sh" ) ;
2908+ // The trailing `rm` releases the lock from the script, not just
2909+ // from `Drop`: the shell outlives a killed run, and only it knows
2910+ // when the publish finished. It runs on the failure path too, and
2911+ // an empty $lk (Unlocked ticket) skips it without disturbing the
2912+ // exit status.
28912913 cmd. arg ( "-c" )
28922914 . arg (
2893- r#"cc="$1"; tso="$2"; tc="$3"; pc="$4"; pso="$5"; shift 5; "$cc" "$@" -o "$tso" "$tc" || { rm -f "$tso"; exit 1; }; mv -f "$tc " "$pc "; mv -f "$tso" "$pso" "# ,
2915+ r#"cc="$1"; tso="$2"; tc="$3"; pc="$4"; pso="$5"; lk="$6"; shift 6; if "$cc" "$@" -o "$tso" "$tc"; then mv -f "$tc" "$pc"; mv -f "$tso " "$pso "; rc=0; else rm -f "$tso"; rc=1; fi; if [ -n "$lk" ]; then rm -f "$lk"; fi; exit $rc "# ,
28942916 )
28952917 . arg ( "sh" )
28962918 . arg ( & cc_name)
@@ -2906,8 +2928,18 @@ fn compile_source_in(cache_dir: &Path, src: &str) -> Result<EmittedModule, Strin
29062928 use std:: os:: unix:: process:: CommandExt ;
29072929 cmd. process_group ( 0 ) ;
29082930 }
2909- cmd. output ( )
2910- . map_err ( |e| format ! ( "spawn sh/cc: {e} (set VERYL_AOT_CC to override)" ) ) ?
2931+ cmd. stdout ( Stdio :: piped ( ) ) . stderr ( Stdio :: piped ( ) ) ;
2932+ let child = cmd
2933+ . spawn ( )
2934+ . map_err ( |e| format ! ( "spawn sh/cc: {e} (set VERYL_AOT_CC to override)" ) ) ?;
2935+ // The shell, not us, owns the publish, so its pid is what tells
2936+ // a waiter whether the lock is still live (see `owner_alive`).
2937+ if let Some ( lp) = & lock_path {
2938+ let _ = std:: fs:: write ( lp, format ! ( "{}\n " , child. id( ) ) ) ;
2939+ }
2940+ child
2941+ . wait_with_output ( )
2942+ . map_err ( |e| format ! ( "wait sh/cc: {e}" ) ) ?
29112943 } ;
29122944 #[ cfg( not( unix) ) ]
29132945 let out = {
@@ -2969,6 +3001,119 @@ fn compile_source_in(cache_dir: &Path, src: &str) -> Result<EmittedModule, Strin
29693001 Ok ( EmittedModule { func, _lib : lib } )
29703002}
29713003
3004+ /// Per-artifact compile lock, released on drop and by the compile script's
3005+ /// trailing `rm` (whichever happens first — the script outlives us when the
3006+ /// process is killed mid-compile).
3007+ struct CompileLock {
3008+ path : PathBuf ,
3009+ }
3010+
3011+ impl Drop for CompileLock {
3012+ fn drop ( & mut self ) {
3013+ let _ = std:: fs:: remove_file ( & self . path ) ;
3014+ }
3015+ }
3016+
3017+ /// Outcome of trying to take the lock for one artifact hash.
3018+ enum CompileTicket {
3019+ /// We own the lock: compile, then release.
3020+ Owned ( CompileLock ) ,
3021+ /// Another compiler published the artifact while we waited.
3022+ Published ,
3023+ /// The lock file is unusable (unwritable cache dir, filesystem without
3024+ /// `O_EXCL` semantics): compile unlocked, exactly as before the lock.
3025+ Unlocked ,
3026+ }
3027+
3028+ /// Backstop for a lock whose owner cannot be identified (the pid is not
3029+ /// written yet, or this is not unix); set above any real compile — the
3030+ /// largest source measured, 87 MB, takes ~4 min. A provably dead owner is
3031+ /// taken over at once instead — see [`owner_alive`].
3032+ const COMPILE_LOCK_STALE : Duration = Duration :: from_secs ( 600 ) ;
3033+
3034+ /// Is the compile shell recorded in a lock file still running?
3035+ ///
3036+ /// The publishing shell outlives us on purpose, so its pid — not ours — is
3037+ /// what makes a lock meaningful. Without this check a run killed mid-compile
3038+ /// would hold the artifact hostage for [`COMPILE_LOCK_STALE`].
3039+ /// `None` = no pid recorded yet; fall back to the age rule.
3040+ fn owner_alive ( lock_path : & Path ) -> Option < bool > {
3041+ let pid: u32 = std:: fs:: read_to_string ( lock_path)
3042+ . ok ( ) ?
3043+ . trim ( )
3044+ . parse ( )
3045+ . ok ( ) ?;
3046+ // Linux: procfs is authoritative and free. Guarded by /proc/self so a
3047+ // system without procfs doesn't read every pid as dead.
3048+ if Path :: new ( "/proc/self" ) . exists ( ) {
3049+ return Some ( Path :: new ( & format ! ( "/proc/{pid}" ) ) . exists ( ) ) ;
3050+ }
3051+ // Other unix: `kill -0` through the shell we already use, so this needs
3052+ // no libc dependency.
3053+ #[ cfg( unix) ]
3054+ let alive = Command :: new ( "sh" )
3055+ . arg ( "-c" )
3056+ . arg ( format ! ( "kill -0 {pid} 2>/dev/null" ) )
3057+ . status ( )
3058+ . ok ( )
3059+ . map ( |s| s. success ( ) ) ;
3060+ #[ cfg( not( unix) ) ]
3061+ let alive = None ;
3062+ alive
3063+ }
3064+
3065+ /// Take the compile lock for `hash`, waiting for the owner rather than
3066+ /// duplicating its work.
3067+ ///
3068+ /// Identical sources hash to one `.so`, so every process and pool worker
3069+ /// reaching the compile would otherwise build the same translation unit
3070+ /// independently — four concurrent `cc1` jobs over one 87 MB source have been
3071+ /// observed within a single run, and concurrent runs duplicate that again.
3072+ /// Waiting costs nothing: `compile_source_in` blocks for the full `cc` either
3073+ /// way, so this only removes redundant work.
3074+ ///
3075+ /// Declining instead of waiting would not be neutral: the caller's cell is a
3076+ /// `OnceLock`, so a worker that gives up leaves that handle on Cranelift for
3077+ /// the rest of the process.
3078+ fn acquire_compile_lock ( cache_dir : & Path , hash : & str , so_path : & Path ) -> CompileTicket {
3079+ let path = cache_dir. join ( format ! ( "veryl_aot_{hash}.lock" ) ) ;
3080+ loop {
3081+ match std:: fs:: OpenOptions :: new ( )
3082+ . write ( true )
3083+ . create_new ( true )
3084+ . open ( & path)
3085+ {
3086+ Ok ( _) => return CompileTicket :: Owned ( CompileLock { path } ) ,
3087+ Err ( e) if e. kind ( ) == ErrorKind :: AlreadyExists => { }
3088+ Err ( _) => return CompileTicket :: Unlocked ,
3089+ }
3090+ if so_path. exists ( ) {
3091+ return CompileTicket :: Published ;
3092+ }
3093+ let dead = match owner_alive ( & path) {
3094+ Some ( alive) => !alive,
3095+ // Owner unknown (pid not written yet, or not unix): age it out.
3096+ None => std:: fs:: metadata ( & path)
3097+ . and_then ( |m| m. modified ( ) )
3098+ . map ( |t| t. elapsed ( ) . unwrap_or_default ( ) > COMPILE_LOCK_STALE )
3099+ // A lock we cannot stat is gone or unreadable; retrying the
3100+ // create resolves both.
3101+ . unwrap_or ( true ) ,
3102+ } ;
3103+ if dead {
3104+ if diag_enabled ( ) {
3105+ eprintln ! (
3106+ "[aot_c] taking over abandoned compile lock {}" ,
3107+ path. display( )
3108+ ) ;
3109+ }
3110+ let _ = std:: fs:: remove_file ( & path) ;
3111+ continue ;
3112+ }
3113+ std:: thread:: sleep ( Duration :: from_millis ( 250 ) ) ;
3114+ }
3115+ }
3116+
29723117fn aot_c_cache_dir ( ) -> Result < PathBuf , String > {
29733118 if let Ok ( p) = std:: env:: var ( "VERYL_AOT_CACHE_DIR" ) {
29743119 return Ok ( PathBuf :: from ( p) ) ;
@@ -5688,6 +5833,7 @@ mod tests {
56885833 ExpressionContext , ProtoAssignStatement , ProtoDynamicBitSelect , ProtoIfStatement ,
56895834 ProtoSystemFunctionCall ,
56905835 } ;
5836+ use std:: time:: Instant ;
56915837 use veryl_analyzer:: value:: ValueU64 ;
56925838 use veryl_parser:: token_range:: TokenRange ;
56935839
@@ -6492,6 +6638,119 @@ mod tests {
64926638 let _ = std:: fs:: remove_dir_all ( & tmp) ;
64936639 }
64946640
6641+ #[ test]
6642+ fn compile_lock_ticket_states ( ) {
6643+ let tmp = std:: env:: temp_dir ( ) . join ( format ! ( "veryl_aot_lock_{}" , std:: process:: id( ) ) ) ;
6644+ std:: fs:: create_dir_all ( & tmp) . unwrap ( ) ;
6645+ let so = tmp. join ( "veryl_aot_deadbeef.so" ) ;
6646+ let lock = tmp. join ( "veryl_aot_deadbeef.lock" ) ;
6647+
6648+ // Fresh: we own the lock, and the file exists while we hold it.
6649+ let ticket = acquire_compile_lock ( & tmp, "deadbeef" , & so) ;
6650+ assert ! ( matches!( ticket, CompileTicket :: Owned ( _) ) ) ;
6651+ assert ! ( lock. exists( ) ) ;
6652+
6653+ // A second caller must not compile the same hash. It waits, so give
6654+ // it the exit the owner's publish provides. (The lock carries no pid
6655+ // yet, so the age rule keeps it alive — exactly the spawn window.)
6656+ std:: fs:: write ( & so, b"not a real object" ) . unwrap ( ) ;
6657+ assert ! ( matches!(
6658+ acquire_compile_lock( & tmp, "deadbeef" , & so) ,
6659+ CompileTicket :: Published
6660+ ) ) ;
6661+
6662+ std:: fs:: remove_file ( & so) . unwrap ( ) ;
6663+
6664+ // Dropping the owner releases the lock even without the script's `rm`
6665+ // (the non-unix path and every early-return error path rely on this).
6666+ drop ( ticket) ;
6667+ assert ! ( !lock. exists( ) ) ;
6668+
6669+ // A lock naming a dead owner is taken over at once rather than after
6670+ // the stale timeout: otherwise a run killed mid-compile would wedge
6671+ // this artifact out of the cache for ten minutes. Only unix can
6672+ // identify the owner; elsewhere `owner_alive` yields the age rule.
6673+ #[ cfg( unix) ]
6674+ {
6675+ // `kill -0 0` addresses our own process group, so name a pid that
6676+ // cannot exist rather than 0.
6677+ std:: fs:: write ( & lock, format ! ( "{}\n " , u32 :: MAX ) ) . unwrap ( ) ;
6678+ assert_eq ! ( owner_alive( & lock) , Some ( false ) ) ;
6679+ let retaken = acquire_compile_lock ( & tmp, "deadbeef" , & so) ;
6680+ assert ! ( matches!( retaken, CompileTicket :: Owned ( _) ) ) ;
6681+ drop ( retaken) ;
6682+ assert ! ( !lock. exists( ) ) ;
6683+ }
6684+
6685+ // An unusable lock directory degrades to compiling unlocked rather
6686+ // than failing the compile.
6687+ assert ! ( matches!(
6688+ acquire_compile_lock(
6689+ & tmp. join( "no" ) . join( "such" ) . join( "dir" ) ,
6690+ "deadbeef" ,
6691+ & tmp. join( "no" ) . join( "such" ) . join( "dir" ) . join( "x.so" ) ,
6692+ ) ,
6693+ CompileTicket :: Unlocked
6694+ ) ) ;
6695+
6696+ let _ = std:: fs:: remove_dir_all ( & tmp) ;
6697+ }
6698+
6699+ #[ test]
6700+ fn compile_lock_serializes_identical_sources ( ) {
6701+ // Concurrent callers of one hash: all must get a working module, the
6702+ // artifact must be published exactly once, and no lock may be left
6703+ // behind (a leaked lock would wedge the artifact until the stale
6704+ // takeover).
6705+ if !cc_available ( ) {
6706+ eprintln ! ( "compile_lock_serializes_identical_sources: cc unavailable, skipping" ) ;
6707+ return ;
6708+ }
6709+ let src = "\
6710+ #include <stdint.h>\n \
6711+ __attribute__((visibility(\" default\" )))\n \
6712+ void veryl_aot_eval(uint8_t *ff, uint8_t *comb, uint64_t *log, intptr_t ff_delta) {\n \
6713+ (void)ff; (void)log; (void)ff_delta;\n \
6714+ *(uint32_t*)(comb + 0) = 0x5a5a5a5a;\n \
6715+ }\n ";
6716+ let tmp = std:: env:: temp_dir ( ) . join ( format ! ( "veryl_aot_lock_cc_{}" , std:: process:: id( ) ) ) ;
6717+ let _ = std:: fs:: remove_dir_all ( & tmp) ;
6718+ std:: fs:: create_dir_all ( & tmp) . unwrap ( ) ;
6719+ let mut handles = Vec :: new ( ) ;
6720+ for _ in 0 ..4 {
6721+ let dir = tmp. clone ( ) ;
6722+ handles. push ( std:: thread:: spawn ( move || {
6723+ compile_source_in ( & dir, src) . map ( |_| ( ) )
6724+ } ) ) ;
6725+ }
6726+ let mut skip = false ;
6727+ for h in handles {
6728+ match h. join ( ) . unwrap ( ) {
6729+ Ok ( ( ) ) => { }
6730+ Err ( e) if e. starts_with ( "dlopen" ) || e. starts_with ( "dlsym" ) => skip = true ,
6731+ Err ( e) => panic ! ( "concurrent compile: {e}" ) ,
6732+ }
6733+ }
6734+ if skip {
6735+ eprintln ! ( "compile_lock_serializes_identical_sources: .so not loadable here; skipping" ) ;
6736+ let _ = std:: fs:: remove_dir_all ( & tmp) ;
6737+ return ;
6738+ }
6739+ let mut so = 0usize ;
6740+ let mut locks = 0usize ;
6741+ for e in std:: fs:: read_dir ( & tmp) . unwrap ( ) {
6742+ let name = e. unwrap ( ) . file_name ( ) . to_string_lossy ( ) . into_owned ( ) ;
6743+ if name. ends_with ( ".so" ) {
6744+ so += 1 ;
6745+ } else if name. ends_with ( ".lock" ) {
6746+ locks += 1 ;
6747+ }
6748+ }
6749+ assert_eq ! ( so, 1 , "one artifact per hash" ) ;
6750+ assert_eq ! ( locks, 0 , "the compile lock must be released" ) ;
6751+ let _ = std:: fs:: remove_dir_all ( & tmp) ;
6752+ }
6753+
64956754 #[ test]
64966755 fn emit_rhs_select_field_into_dst_select_rmw ( ) {
64976756 // A per-bank extract: dst96[23:0] = (x700[174:0])[174:151] —
@@ -7685,7 +7944,7 @@ mod tests {
76857944 std:: thread:: spawn ( move || {
76867945 let _ = compile_source_in ( & dir, "// AOT-C publish probe\n " ) ;
76877946 } ) ;
7688- std:: thread:: sleep ( std :: time :: Duration :: from_millis ( 200 ) ) ;
7947+ std:: thread:: sleep ( Duration :: from_millis ( 200 ) ) ;
76897948 std:: process:: exit ( 0 ) ;
76907949 }
76917950
@@ -7706,8 +7965,8 @@ mod tests {
77067965 . arg ( "aot_cache_publish_survives_process_exit" )
77077966 . env ( CHILD_DIR , & tmp)
77087967 . env ( "VERYL_AOT_CC" , & slow_cc)
7709- . stdout ( std :: process :: Stdio :: null ( ) )
7710- . stderr ( std :: process :: Stdio :: null ( ) )
7968+ . stdout ( Stdio :: null ( ) )
7969+ . stderr ( Stdio :: null ( ) )
77117970 . status ( )
77127971 . unwrap ( ) ;
77137972 assert ! ( status. success( ) , "child run failed" ) ;
@@ -7724,9 +7983,9 @@ mod tests {
77247983 n. starts_with ( "veryl_aot_" ) && n. ends_with ( ".so" ) && n. matches ( '.' ) . count ( ) == 1
77257984 } )
77267985 } ;
7727- let deadline = std :: time :: Instant :: now ( ) + std :: time :: Duration :: from_secs ( 30 ) ;
7728- while !published ( ) && std :: time :: Instant :: now ( ) < deadline {
7729- std:: thread:: sleep ( std :: time :: Duration :: from_millis ( 100 ) ) ;
7986+ let deadline = Instant :: now ( ) + Duration :: from_secs ( 30 ) ;
7987+ while !published ( ) && Instant :: now ( ) < deadline {
7988+ std:: thread:: sleep ( Duration :: from_millis ( 100 ) ) ;
77307989 }
77317990 assert ! ( published( ) , "the .so must publish after the run exits" ) ;
77327991 let _ = std:: fs:: remove_dir_all ( & tmp) ;
0 commit comments