@ape-egg/vibe 2.1.20 → 2.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.21] - 2026-07-01
4
+
5
+ ### Fixed
6
+
7
+ - **An orphaned `--watch` compiler held its lock forever, blocking every future watch on the same output** (`compiler/bin/vibe-compile.js`, `compiler/src/compiler/watcher.rs`, compiler 2.0.2) — the watch lock added below is only released on clean shutdown (Drop), but the compiler is a native child spawned by the `vibe-compile.js` wrapper, and killing the wrapper (Ctrl+C, a dev server's `child.kill()`, a process manager) left that child alive as an orphan still holding the lock — so the next `--watch` exited loudly naming a holder pid that was long gone. Two coordinated fixes close both escape routes: **(1)** the wrapper now forwards `SIGINT`/`SIGTERM`/`SIGHUP` and its own `exit` to the spawned child (`wireLifecycle`), so a graceful kill of the wrapper unwinds the watcher's Drop and frees the lock normally; **(2)** for the ungraceful case (`SIGKILL`, a crashed parent) where no signal is forwarded, the watcher polls its own parent pid on a background thread (`exit_when_orphaned`, unix-only) and, the moment it's reparented to init/a reaper — the unambiguous orphan signal — removes its lockfile and `process::exit`s, since `exit` skips Drop. Together they guarantee a dead owner never leaves a lock behind, complementing the stale-lock stealing the next watcher already does.
8
+ - **Concurrent compilers corrupted hyperspeed manifests → pages hydrated blank with no errors** (`compiler/src/compiler/compile.rs`, `compiler/src/compiler/watcher.rs`, compiler 2.0.1 → 2.0.2) — manifest generation re-read each compiled page **from disk** after writing it, so when two `vibe compile --watch` processes shared one output directory (a leaked dev-server session next to a live one), one watcher's manifest pass could read a page the other was mid-rewrite. html5ever parses the torn prefix into a well-formed shell, producing a *valid but content-less* manifest that doesn't match its HTML — hydration mounts nothing, `vibe-fouc` never releases, and the page renders blank with zero console errors (observed in Battle Brawlers: a random subset of pages broke on every save of a widely-used component). Three-layer fix: **(1)** the compiler keeps each page's compiled HTML in memory (`Compiler.compiled_html`) and both manifest passes (`generate_manifests`, `generate_manifests_for_files`) build from those exact bytes — disk is only a fallback for pages the running compiler never produced (no-clean leftovers); **(2)** compiled HTML, stamped HTML, and manifests are written atomically (same-directory pid-tagged temp file + `rename`) so no reader — dev server, browser, or another process — can ever observe a partial file; **(3)** `watch` takes a per-output-directory lockfile (OS temp dir, keyed by canonicalized output path, holding the owner pid): a second watcher on the same output now exits loudly naming the holder instead of silently double-compiling and racing, and a lock whose process is dead (Ctrl+C/SIGTERM never unwind) is stolen. Tests: `compile.rs` (`manifest_survives_output_corruption_between_compile_and_manifests`, `incremental_manifest_survives_output_corruption`, `atomic_write_replaces_content_without_leaving_tmp_files`), `watcher.rs` (`second_watch_lock_on_same_output_fails_while_held`, `stale_lock_from_dead_process_is_stolen`, `locks_on_different_outputs_do_not_conflict`).
9
+
3
10
  ## [2.1.20] - 2026-06-25
4
11
 
5
12
  ### Added
@@ -35,6 +35,16 @@ const getPlatformBinary = () => {
35
35
  return binary;
36
36
  };
37
37
 
38
+ // The wrapper is what gets killed (Ctrl+C, a dev server's child.kill(), a
39
+ // process manager) — without forwarding, the compiler child survives as an
40
+ // orphan whose watch lock blocks every future `--watch` on the same output.
41
+ const wireLifecycle = (child) => {
42
+ ['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((signal) =>
43
+ process.on(signal, () => child.kill(signal)),
44
+ );
45
+ process.on('exit', () => child.kill());
46
+ };
47
+
38
48
  const run = () => {
39
49
  const binary = getPlatformBinary();
40
50
  const binaryPath = join(nativeDir, binary);
@@ -74,6 +84,7 @@ const run = () => {
74
84
  cwd: srcDir,
75
85
  stdio: 'inherit',
76
86
  });
87
+ wireLifecycle(cargo);
77
88
 
78
89
  cargo.on('error', (err) => {
79
90
  if (err.code === 'ENOENT') {
@@ -95,6 +106,7 @@ const run = () => {
95
106
  const child = spawn(binaryPath, userArgs, {
96
107
  stdio: 'inherit',
97
108
  });
109
+ wireLifecycle(child);
98
110
 
99
111
  child.on('error', (err) => {
100
112
  console.error('Failed to run vibe-compiler:', err.message);
@@ -1599,7 +1599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1599
1599
 
1600
1600
  [[package]]
1601
1601
  name = "vibe-compiler"
1602
- version = "2.0.1"
1602
+ version = "2.0.2"
1603
1603
  dependencies = [
1604
1604
  "clap",
1605
1605
  "colored",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "2.0.1"
3
+ version = "2.0.2"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -39,6 +39,17 @@ fn manifest_url_path(relative_path: &str, root: Option<&str>) -> String {
39
39
  .join("/")
40
40
  }
41
41
 
42
+ /// Write via a same-directory temp file + rename, so no reader — the dev
43
+ /// server, the browser, another compiler process — can ever observe a
44
+ /// partially-written file. Rename is atomic on POSIX; the temp name carries the
45
+ /// pid so two processes writing the same target never share a temp file.
46
+ pub(crate) fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
47
+ let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("out");
48
+ let tmp = path.with_file_name(format!(".{}.{}.vibe-tmp", file_name, std::process::id()));
49
+ fs::write(&tmp, contents)?;
50
+ fs::rename(&tmp, path)
51
+ }
52
+
42
53
  // =============================================================================
43
54
  // MIRROR_MODE: Copy asset files from source to output as-is, preserving
44
55
  // directory structure. HTML files are compiled separately.
@@ -544,6 +555,11 @@ pub struct Compiler {
544
555
  unique_components: HashSet<String>,
545
556
  /// Cache for all components (both internal paths and external URLs)
546
557
  component_cache: std::collections::HashMap<String, String>,
558
+ /// Compiled (pre-stamp) HTML of every page this compiler wrote, keyed by
559
+ /// output path. Manifest generation builds from these bytes — never from a
560
+ /// disk read-back — so a concurrent writer rewriting the output directory
561
+ /// (e.g. a second compiler) can't feed a torn read into a manifest.
562
+ compiled_html: HashMap<PathBuf, String>,
547
563
  }
548
564
 
549
565
  impl Compiler {
@@ -555,6 +571,7 @@ impl Compiler {
555
571
  logger,
556
572
  unique_components: HashSet::new(),
557
573
  component_cache: HashMap::new(),
574
+ compiled_html: HashMap::new(),
558
575
  }
559
576
  }
560
577
 
@@ -898,6 +915,7 @@ impl Compiler {
898
915
  // Resolve constant global-state keys once, shared read-only across pages.
899
916
  let global_constants = self.compute_global_constants();
900
917
 
918
+ let compiled_html = &self.compiled_html;
901
919
  let results: Vec<bool> = files
902
920
  .par_iter()
903
921
  .map(|file_path| {
@@ -911,13 +929,24 @@ impl Compiler {
911
929
  return false;
912
930
  }
913
931
 
914
- let html = match fs::read_to_string(&output_path) {
915
- Ok(h) => h,
916
- Err(_) => return false,
932
+ // The HTML this compiler produced in memory is the source of
933
+ // truth; the on-disk file may have been rewritten by another
934
+ // process since we wrote it. Disk is only a fallback for pages
935
+ // this compiler never compiled (e.g. no-clean leftovers).
936
+ let disk_html;
937
+ let html: &str = match compiled_html.get(&output_path) {
938
+ Some(h) => h,
939
+ None => {
940
+ disk_html = match fs::read_to_string(&output_path) {
941
+ Ok(h) => h,
942
+ Err(_) => return false,
943
+ };
944
+ &disk_html
945
+ }
917
946
  };
918
947
 
919
948
  match Self::generate_file_manifest(
920
- &html,
949
+ html,
921
950
  &output_path,
922
951
  &output_dir,
923
952
  relative_path,
@@ -1009,7 +1038,7 @@ impl Compiler {
1009
1038
  manifest_json
1010
1039
  );
1011
1040
 
1012
- fs::write(&manifest_path, manifest_js)
1041
+ atomic_write(&manifest_path, &manifest_js)
1013
1042
  .map_err(|e| format!("Failed to write manifest: {}", e))?;
1014
1043
 
1015
1044
  // Stamp the compiled HTML with initial state values for FOUC prevention:
@@ -1023,7 +1052,7 @@ impl Compiler {
1023
1052
  let stamped = stamper.stamp_html(html.to_string())
1024
1053
  .map_err(|e| format!("Failed to stamp HTML: {}", e))?;
1025
1054
 
1026
- fs::write(html_path, stamped)
1055
+ atomic_write(html_path, &stamped)
1027
1056
  .map_err(|e| format!("Failed to write stamped HTML: {}", e))?;
1028
1057
 
1029
1058
  Ok(())
@@ -1108,6 +1137,7 @@ impl Compiler {
1108
1137
  // pages (read-only; `&Map` is Sync so the parallel map can borrow it).
1109
1138
  let global_constants = self.compute_global_constants();
1110
1139
 
1140
+ let compiled_html = &self.compiled_html;
1111
1141
  let results: Vec<_> = html_files
1112
1142
  .par_iter()
1113
1143
  .map(|html_path| {
@@ -1116,14 +1146,24 @@ impl Compiler {
1116
1146
  .to_str()
1117
1147
  .unwrap();
1118
1148
 
1119
- // Read compiled HTML
1120
- let html = match fs::read_to_string(html_path) {
1121
- Ok(h) => h,
1122
- Err(_) => return (false, Some(relative_path.to_string())),
1149
+ // Prefer the HTML this compiler produced in memory — the disk
1150
+ // copy may have been rewritten by another process since. Disk
1151
+ // is only a fallback for pages this compiler never compiled
1152
+ // (e.g. no-clean leftovers from an earlier run).
1153
+ let disk_html;
1154
+ let html: &str = match compiled_html.get(html_path.as_path()) {
1155
+ Some(h) => h,
1156
+ None => {
1157
+ disk_html = match fs::read_to_string(html_path) {
1158
+ Ok(h) => h,
1159
+ Err(_) => return (false, Some(relative_path.to_string())),
1160
+ };
1161
+ &disk_html
1162
+ }
1123
1163
  };
1124
1164
 
1125
1165
  // Try to generate manifest for this file (skip on error)
1126
- match Self::generate_file_manifest(&html, html_path, &output_dir, relative_path, verbose, iterations_as_is, components_as_is, &source_root, manifest_root.as_deref(), &global_constants) {
1166
+ match Self::generate_file_manifest(html, html_path, &output_dir, relative_path, verbose, iterations_as_is, components_as_is, &source_root, manifest_root.as_deref(), &global_constants) {
1127
1167
  Ok(()) => (true, None),
1128
1168
  Err(e) => {
1129
1169
  if verbose {
@@ -1320,7 +1360,7 @@ impl Compiler {
1320
1360
  }
1321
1361
 
1322
1362
  fn compile_html_file(
1323
- &self,
1363
+ &mut self,
1324
1364
  path: &Path,
1325
1365
  parser: &HtmlParser,
1326
1366
  relative_path: &str,
@@ -1361,11 +1401,14 @@ impl Compiler {
1361
1401
 
1362
1402
  // Write to output
1363
1403
  let output_path = self.get_output_path(path, relative_path)?;
1364
- fs::write(&output_path, output).map_err(|e| CompileError::WriteError {
1404
+ atomic_write(&output_path, &output).map_err(|e| CompileError::WriteError {
1365
1405
  path: output_path.display().to_string(),
1366
1406
  source: e,
1367
1407
  })?;
1368
1408
 
1409
+ // Keep the exact bytes for this pass's manifest generation.
1410
+ self.compiled_html.insert(output_path, output);
1411
+
1369
1412
  // Return component counts and src list
1370
1413
  Ok((internal_count, external_count, component_srcs))
1371
1414
  }
@@ -2258,4 +2301,130 @@ mod tests {
2258
2301
  let style = &out[out.find("<style").unwrap()..out.find("</style>").unwrap()];
2259
2302
  assert!(style.contains('\n'), "style newlines collapsed: {style:?}");
2260
2303
  }
2304
+
2305
+ #[test]
2306
+ fn atomic_write_replaces_content_without_leaving_tmp_files() {
2307
+ let dir = std::env::temp_dir().join("vibe_atomic_write_test");
2308
+ let _ = fs::remove_dir_all(&dir);
2309
+ fs::create_dir_all(&dir).unwrap();
2310
+ let target = dir.join("page.html");
2311
+
2312
+ atomic_write(&target, "first").unwrap();
2313
+ assert_eq!(fs::read_to_string(&target).unwrap(), "first");
2314
+
2315
+ // Overwriting an existing file goes through the same tmp+rename path.
2316
+ atomic_write(&target, "second, longer content").unwrap();
2317
+ assert_eq!(fs::read_to_string(&target).unwrap(), "second, longer content");
2318
+
2319
+ let leftovers: Vec<String> = fs::read_dir(&dir)
2320
+ .unwrap()
2321
+ .filter_map(|e| e.ok())
2322
+ .map(|e| e.file_name().to_string_lossy().into_owned())
2323
+ .filter(|n| n != "page.html")
2324
+ .collect();
2325
+ assert!(leftovers.is_empty(), "temp artifacts left behind: {leftovers:?}");
2326
+ }
2327
+
2328
+ // A minimal on-disk project: source with one page carrying a distinctive
2329
+ // binding, empty components dir, output dir sibling. Returns (config, page
2330
+ // source path, compiled page output path, manifest path).
2331
+ fn manifest_test_project(name: &str) -> (Config, PathBuf, PathBuf, PathBuf) {
2332
+ let dir = std::env::temp_dir().join(format!("vibe_{}_test", name));
2333
+ let _ = fs::remove_dir_all(&dir);
2334
+ let source = dir.join("src");
2335
+ let output = dir.join("out");
2336
+ fs::create_dir_all(source.join("components")).unwrap();
2337
+ fs::write(
2338
+ source.join("index.html"),
2339
+ "<!doctype html>\n<html><head><title>t</title></head>\n\
2340
+ <body vibe>\n<page-home><h1>@[uniqueMarker123]</h1></page-home>\n</body></html>\n",
2341
+ )
2342
+ .unwrap();
2343
+
2344
+ let config = Config {
2345
+ source: source.clone(),
2346
+ output: output.clone(),
2347
+ _source_str: String::new(),
2348
+ _output_str: String::new(),
2349
+ components: "components".to_string(),
2350
+ pages: "pages".to_string(),
2351
+ _assets: String::new(),
2352
+ root: None,
2353
+ minify: false,
2354
+ elements_as_is: false,
2355
+ source_maps: false,
2356
+ reserved_elements: Vec::new(),
2357
+ skip_files: Vec::new(),
2358
+ node_modules_as_is: false,
2359
+ components_as_is: false,
2360
+ runtime_as_is: false,
2361
+ iterations_as_is: false,
2362
+ no_clean: false,
2363
+ fouc_as_is: false,
2364
+ working_dir: dir.clone(),
2365
+ };
2366
+
2367
+ let page_src = source.join("index.html");
2368
+ let page_out = output.join("index.html");
2369
+ let manifest = output.join("vibe-hyperspeed").join("index.html.manifest.js");
2370
+ (config, page_src, page_out, manifest)
2371
+ }
2372
+
2373
+ // The watcher race: another writer truncates/rewrites a compiled page on
2374
+ // disk between our compile and our manifest pass. The manifest must be
2375
+ // built from the HTML this compiler just produced in memory — never from a
2376
+ // disk read-back — or a torn read yields a valid-but-empty manifest and the
2377
+ // page hydrates to a blank screen.
2378
+ #[test]
2379
+ fn manifest_survives_output_corruption_between_compile_and_manifests() {
2380
+ let (config, _page_src, page_out, manifest) =
2381
+ manifest_test_project("manifest_memory_full");
2382
+
2383
+ let mut compiler = Compiler::new(config, false);
2384
+ compiler.compile().expect("compile should succeed");
2385
+ assert!(
2386
+ fs::read_to_string(&page_out).unwrap().contains("uniqueMarker123"),
2387
+ "sanity: compiled page carries the binding"
2388
+ );
2389
+
2390
+ // Simulate the concurrent writer: the on-disk page is now a shell.
2391
+ fs::write(&page_out, "<!doctype html>\n<html><head></head><body></body></html>\n").unwrap();
2392
+
2393
+ compiler.generate_manifests().expect("manifest generation should succeed");
2394
+
2395
+ let manifest_js = fs::read_to_string(&manifest).expect("manifest should exist");
2396
+ assert!(
2397
+ manifest_js.contains("uniqueMarker123"),
2398
+ "manifest was built from the corrupted disk file instead of the in-memory compile output"
2399
+ );
2400
+ }
2401
+
2402
+ // Same property on the incremental watch path (generate_manifests_for_files),
2403
+ // which is where the two-watcher race actually corrupted manifests.
2404
+ #[test]
2405
+ fn incremental_manifest_survives_output_corruption() {
2406
+ let (config, page_src, page_out, manifest) =
2407
+ manifest_test_project("manifest_memory_incremental");
2408
+
2409
+ let mut compiler = Compiler::new(config.clone(), false);
2410
+ let mut parser = HtmlParser::new(config.components_path());
2411
+ parser.load_elements().unwrap();
2412
+
2413
+ fs::create_dir_all(&config.output).unwrap();
2414
+ compiler
2415
+ .compile_specific_html_files(&[page_src.clone()], &parser)
2416
+ .expect("incremental compile should succeed");
2417
+
2418
+ fs::write(&page_out, "<!doctype html>\n<html><head></head><body></body></html>\n").unwrap();
2419
+
2420
+ compiler
2421
+ .generate_manifests_for_files(&[page_src])
2422
+ .expect("incremental manifest generation should succeed");
2423
+
2424
+ let manifest_js = fs::read_to_string(&manifest).expect("manifest should exist");
2425
+ assert!(
2426
+ manifest_js.contains("uniqueMarker123"),
2427
+ "incremental manifest was built from the corrupted disk file instead of the in-memory compile output"
2428
+ );
2429
+ }
2261
2430
  }
@@ -206,6 +206,114 @@ fn component_cache_key(component: &Path, canonical_source: &Path) -> Option<Stri
206
206
  .map(|rel| format!("/{}", rel.replace('\\', "/")))
207
207
  }
208
208
 
209
+ /// Cross-process guard: exactly one `vibe compile --watch` per output
210
+ /// directory. Two concurrent watchers double-compile every save and race each
211
+ /// other's output writes — the loser reads a torn file back and emits a
212
+ /// valid-but-empty manifest, so the page hydrates blank with no errors. The
213
+ /// lock lives in the OS temp dir keyed by the output path, holds the owner's
214
+ /// pid, and a lock whose process is gone is stolen (a killed watcher never
215
+ /// unwinds, so Drop alone can't be trusted to clean up).
216
+ #[derive(Debug)]
217
+ pub struct WatchLock {
218
+ path: PathBuf,
219
+ }
220
+
221
+ impl WatchLock {
222
+ /// Deterministic lock path for an output dir, stable whether or not the
223
+ /// output exists yet: canonicalize the output itself when possible, else
224
+ /// its (existing) parent — so a watcher that locked before the first
225
+ /// compile created the output still collides with one that locked after.
226
+ fn lock_path_for(output: &Path) -> PathBuf {
227
+ use std::collections::hash_map::DefaultHasher;
228
+ use std::hash::{Hash, Hasher};
229
+
230
+ let canonical = output.canonicalize().unwrap_or_else(|_| {
231
+ let parent = output.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
232
+ let name = output.file_name().map(PathBuf::from).unwrap_or_default();
233
+ parent
234
+ .canonicalize()
235
+ .unwrap_or_else(|_| parent.to_path_buf())
236
+ .join(name)
237
+ });
238
+
239
+ let mut hasher = DefaultHasher::new();
240
+ canonical.hash(&mut hasher);
241
+ std::env::temp_dir().join(format!("vibe-watch-{:016x}.lock", hasher.finish()))
242
+ }
243
+
244
+ pub fn acquire(output: &Path) -> Result<Self, String> {
245
+ let path = Self::lock_path_for(output);
246
+
247
+ // Two attempts: the second runs only after a stale lock was removed.
248
+ for _ in 0..2 {
249
+ match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
250
+ Ok(mut file) => {
251
+ use std::io::Write;
252
+ let _ = write!(file, "{}", std::process::id());
253
+ return Ok(Self { path });
254
+ }
255
+ Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
256
+ let holder = std::fs::read_to_string(&path)
257
+ .ok()
258
+ .and_then(|s| s.trim().parse::<u32>().ok());
259
+ match holder {
260
+ Some(pid) if process_alive(pid) => {
261
+ return Err(format!(
262
+ "another `vibe compile --watch` (pid {}) is already watching this output directory. \
263
+ Concurrent watchers race each other's writes and corrupt manifests — stop the other one first. \
264
+ (lock: {})",
265
+ pid,
266
+ path.display()
267
+ ));
268
+ }
269
+ // Dead owner or unreadable lock: stale, steal it.
270
+ _ => {
271
+ let _ = std::fs::remove_file(&path);
272
+ }
273
+ }
274
+ }
275
+ Err(e) => {
276
+ return Err(format!(
277
+ "failed to create watch lock {}: {}",
278
+ path.display(),
279
+ e
280
+ ));
281
+ }
282
+ }
283
+ }
284
+
285
+ Err(format!(
286
+ "could not acquire watch lock {} — still held after stale-lock cleanup",
287
+ path.display()
288
+ ))
289
+ }
290
+ }
291
+
292
+ impl Drop for WatchLock {
293
+ fn drop(&mut self) {
294
+ let _ = std::fs::remove_file(&self.path);
295
+ }
296
+ }
297
+
298
+ #[cfg(unix)]
299
+ fn process_alive(pid: u32) -> bool {
300
+ std::process::Command::new("kill")
301
+ .arg("-0")
302
+ .arg(pid.to_string())
303
+ .stdout(std::process::Stdio::null())
304
+ .stderr(std::process::Stdio::null())
305
+ .status()
306
+ .map(|s| s.success())
307
+ .unwrap_or(false)
308
+ }
309
+
310
+ /// Without a portable liveness probe, treat an existing lock as live — failing
311
+ /// loudly (with the lock path in the message) beats silently racing.
312
+ #[cfg(not(unix))]
313
+ fn process_alive(_pid: u32) -> bool {
314
+ true
315
+ }
316
+
209
317
  /// Check if a path should be blacklisted based on SKIP_FILES patterns
210
318
  /// This checks both the filename and all path components relative to source root
211
319
  fn is_path_blacklisted(path: &Path, source_root: &Path, skip_files: &[String]) -> bool {
@@ -297,8 +405,34 @@ fn scan_directory(
297
405
  Ok(())
298
406
  }
299
407
 
408
+ /// A watcher whose spawning wrapper dies without unwinding (SIGKILL, crashed
409
+ /// dev server) is orphaned: it keeps watching and its lock blocks every future
410
+ /// `--watch` on the same output. Reparenting is the orphan signal — when the
411
+ /// parent pid changes (to init or a reaper), the owner is gone, so release the
412
+ /// lock and exit. `std::process::exit` skips Drop, hence the explicit remove.
413
+ #[cfg(unix)]
414
+ fn exit_when_orphaned(lock_path: PathBuf) {
415
+ let parent = std::os::unix::process::parent_id();
416
+ std::thread::spawn(move || loop {
417
+ std::thread::sleep(Duration::from_secs(2));
418
+ if std::os::unix::process::parent_id() != parent {
419
+ eprintln!("parent process exited — shutting down watcher");
420
+ let _ = std::fs::remove_file(&lock_path);
421
+ std::process::exit(0);
422
+ }
423
+ });
424
+ }
425
+
426
+ #[cfg(not(unix))]
427
+ fn exit_when_orphaned(_lock_path: PathBuf) {}
428
+
300
429
  /// Start watching for file changes
301
430
  pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn std::error::Error>> {
431
+ // Held for the watcher's whole lifetime; a second watcher on the same
432
+ // output exits loudly instead of silently racing this one.
433
+ let _watch_lock = WatchLock::acquire(&config.output)?;
434
+ exit_when_orphaned(_watch_lock.path.clone());
435
+
302
436
  println!("{}", "Building dependency graph...".cyan());
303
437
  let mut graph = build_dependency_graph(&config)?;
304
438
 
@@ -912,6 +1046,57 @@ mod tests {
912
1046
  );
913
1047
  }
914
1048
 
1049
+ fn lock_test_output(name: &str) -> PathBuf {
1050
+ let out = std::env::temp_dir().join(format!("vibe_watch_lock_{}_out", name));
1051
+ let _ = std::fs::remove_dir_all(&out);
1052
+ std::fs::create_dir_all(&out).unwrap();
1053
+ // A previous crashed test run may have left a lock behind.
1054
+ let _ = std::fs::remove_file(WatchLock::lock_path_for(&out));
1055
+ out
1056
+ }
1057
+
1058
+ // Two concurrent watchers on one output dir double-compile every save and
1059
+ // race each other's writes (torn manifest reads → blank pages). The second
1060
+ // watcher must refuse to start while the first holds the lock.
1061
+ #[test]
1062
+ fn second_watch_lock_on_same_output_fails_while_held() {
1063
+ let out = lock_test_output("same");
1064
+
1065
+ let first = WatchLock::acquire(&out).expect("first lock acquires");
1066
+ let second = WatchLock::acquire(&out);
1067
+ let msg = second.expect_err("second watcher on the same output must fail loudly");
1068
+ assert!(
1069
+ msg.contains("vibe compile --watch"),
1070
+ "error should explain the conflict: {msg}"
1071
+ );
1072
+
1073
+ drop(first);
1074
+ WatchLock::acquire(&out).expect("released lock can be re-acquired");
1075
+ }
1076
+
1077
+ // A watcher killed without unwinding (Ctrl+C, SIGTERM from dev tooling)
1078
+ // leaves its lock file behind; the pid inside is dead, so the next watcher
1079
+ // steals the lock instead of being locked out forever.
1080
+ #[test]
1081
+ fn stale_lock_from_dead_process_is_stolen() {
1082
+ let out = lock_test_output("stale");
1083
+
1084
+ // No live process can have this pid (pid_max is 99998 on macOS,
1085
+ // ≤ 4194304 on Linux).
1086
+ std::fs::write(WatchLock::lock_path_for(&out), "4294967295").unwrap();
1087
+
1088
+ WatchLock::acquire(&out).expect("stale lock from a dead process must be stolen");
1089
+ }
1090
+
1091
+ #[test]
1092
+ fn locks_on_different_outputs_do_not_conflict() {
1093
+ let out_a = lock_test_output("indep_a");
1094
+ let out_b = lock_test_output("indep_b");
1095
+
1096
+ let _a = WatchLock::acquire(&out_a).expect("lock a");
1097
+ WatchLock::acquire(&out_b).expect("an unrelated output dir must not be blocked");
1098
+ }
1099
+
915
1100
  #[test]
916
1101
  fn cache_key_is_source_relative_with_leading_slash() {
917
1102
  let source = PathBuf::from("/proj/src");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.1.20",
3
+ "version": "2.1.21",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",