Skip to content

Commit 5b00bcf

Browse files
authored
Stabilize rewatch scheduling and integration tests (#8667)
* Fix deterministic rewatch scheduling Signed-off-by: Christoph Knittel <ck@cca.io> * Stabilize rewatch integration tests Signed-off-by: Christoph Knittel <ck@cca.io> * Preserve blocked dependents across full rewatch rebuilds Signed-off-by: Christoph Knittel <ck@cca.io> * Abort warning persistence test if watcher stays running Signed-off-by: Christoph Knittel <ck@cca.io> * Abort watcher tests when shutdown times out Signed-off-by: Christoph Knittel <ck@cca.io> --------- Signed-off-by: Christoph Knittel <ck@cca.io>
1 parent e26c2e5 commit 5b00bcf

29 files changed

Lines changed: 490 additions & 129 deletions

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121
#### :bug: Bug fix
2222

23+
- Make rewatch compile independent modules after an unrelated failure and recompile blocked dependents when a changed interface survives a failed implementation, including across full watcher rebuilds. https://github.com/rescript-lang/rescript/pull/8667
24+
2325
#### :memo: Documentation
2426

2527
#### :nail_care: Polish

‎rewatch/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,5 +217,5 @@ When clippy suggests refactoring that could impact performance, consider the tra
217217
## CI Gotchas
218218

219219
- **`sleep` is fragile** — Prefer polling (e.g., `wait_for_file`) over fixed sleeps. CI runners are slower than local machines.
220-
- **`exit_watcher` is async** — It only signals the watcher to stop (removes the lock file), it doesn't wait for the process to exit. Avoid triggering config-change events before exiting, as the watcher may start a concurrent rebuild.
220+
- **Wait for watcher shutdown with `exit_watcher`** — It removes the lock file and waits for the recorded watcher process to exit. Check its return status before continuing when later mutations could race with the watcher.
221221
- **`sed -i` differs across platforms** — macOS requires `sed -i '' ...`, Linux does not. Use the `replace` / `normalize_paths` helpers from `rewatch/tests/utils.sh` instead of raw `sed`.

‎rewatch/src/build/compile.rs‎

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,11 @@ pub fn compile(
440440
rayon::in_place_scope(|scope| {
441441
let mut in_flight: usize = 0;
442442
loop {
443-
while in_flight < capacity && !has_errors {
443+
// Keep dispatching work that was already independent of any
444+
// failed module. Otherwise diagnostics vary with worker count:
445+
// a warning-producing module may or may not have started before
446+
// an unrelated error finishes.
447+
while in_flight < capacity {
444448
let Some(work) = ready_heap.pop() else { break };
445449
let module_name = work.module_name.clone();
446450
let is_dirty = dirty_set.contains(&module_name);
@@ -462,10 +466,6 @@ pub fn compile(
462466
}
463467

464468
if in_flight == 0 {
465-
if !ready_heap.is_empty() {
466-
// Errors suppressed new spawns; nothing left to drain.
467-
break;
468-
}
469469
if completed.len() < compile_universe_count && !has_errors {
470470
stalled = true;
471471
}
@@ -475,7 +475,8 @@ pub fn compile(
475475
let Ok(msg) = rx.recv() else { break };
476476
in_flight -= 1;
477477

478-
if msg.result.is_err() || msg.interface_result.as_ref().is_some_and(|r| r.is_err()) {
478+
let failed = msg.result.is_err() || msg.interface_result.as_ref().is_some_and(|r| r.is_err());
479+
if failed {
479480
has_errors = true;
480481
}
481482

@@ -493,16 +494,21 @@ pub fn compile(
493494
if !compile_universe.contains(dep) {
494495
continue;
495496
}
497+
// A successful interface can replace the CMI even when its
498+
// implementation fails. Keep dependents dirty for recovery,
499+
// but do not release them until the module succeeds.
496500
if !is_clean {
497501
dirty_set.insert(dep.clone());
498502
}
499-
let count = pending_deps.get_mut(dep).unwrap();
500-
*count -= 1;
501-
if *count == 0 && !completed.contains(dep) {
502-
ready_heap.push(WorkUnit {
503-
priority: priorities[dep],
504-
module_name: dep.clone(),
505-
});
503+
if !failed {
504+
let count = pending_deps.get_mut(dep).unwrap();
505+
*count -= 1;
506+
if *count == 0 && !completed.contains(dep) {
507+
ready_heap.push(WorkUnit {
508+
priority: priorities[dep],
509+
module_name: dep.clone(),
510+
});
511+
}
506512
}
507513
}
508514
}
@@ -523,9 +529,9 @@ pub fn compile(
523529
let mut num_compiled_modules = 0;
524530

525531
// Persist propagated dirtiness back onto build_state. Modules that were
526-
// marked dirty (because a predecessor's cmi changed) but never scheduled
527-
// — e.g. the first compile error aborted further dispatch — must keep
528-
// compile_dirty = true so the next incremental build recompiles them.
532+
// marked dirty (because a predecessor's cmi changed) but blocked by a
533+
// failed prerequisite must keep compile_dirty = true so the next
534+
// incremental build recompiles them.
529535
// Successful recompiles in the result loop below override this back to
530536
// false for the modules that actually ran.
531537
for name in &dirty_set {

‎rewatch/src/watcher.rs‎

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ fn unregister_watches(watcher: &mut RecommendedWatcher, watch_paths: &[(PathBuf,
172172
}
173173
}
174174

175-
fn carry_forward_compile_warnings(previous: &BuildCommandState, next: &mut BuildCommandState) {
175+
fn carry_forward_compile_state(previous: &BuildCommandState, next: &mut BuildCommandState) {
176176
for (module_name, next_module) in next.build_state.modules.iter_mut() {
177177
let Some(previous_module) = previous.build_state.modules.get(module_name) else {
178178
continue;
@@ -184,6 +184,12 @@ fn carry_forward_compile_warnings(previous: &BuildCommandState, next: &mut Build
184184
match (&previous_module.source_type, &mut next_module.source_type) {
185185
(SourceType::SourceFile(previous_source), SourceType::SourceFile(next_source)) => {
186186
if previous_source.implementation.path == next_source.implementation.path {
187+
// A changed CMI can leave a dependent blocked behind a failed
188+
// implementation. Asset timestamps alone cannot recover this
189+
// dirtiness when a full watcher rebuild recreates the state.
190+
if previous_module.compile_dirty {
191+
next_module.compile_dirty = true;
192+
}
187193
next_source.implementation.compile_warnings =
188194
previous_source.implementation.compile_warnings.clone();
189195

@@ -501,9 +507,8 @@ async fn async_watch(
501507
.expect("Could not initialize build");
502508

503509
// Full rebuilds can be triggered by editor atomic saves that surface as rename events.
504-
// Preserve warning state for unchanged modules so their warnings are re-emitted after the
505-
// fresh build state replaces the previous one.
506-
carry_forward_compile_warnings(&build_state, &mut next_build_state);
510+
// Preserve warnings and blocked dirty modules when fresh state replaces the previous one.
511+
carry_forward_compile_state(&build_state, &mut next_build_state);
507512
build_state = next_build_state;
508513

509514
// Re-register watches based on the new build state
@@ -804,7 +809,7 @@ mod tests {
804809
);
805810
let mut next = test_build_state("ModuleA", test_module("src/ModuleA.res", None, None, None));
806811

807-
carry_forward_compile_warnings(&previous, &mut next);
812+
carry_forward_compile_state(&previous, &mut next);
808813

809814
let module = next.get_module("ModuleA").expect("module should exist");
810815
let SourceType::SourceFile(source_file) = &module.source_type else {
@@ -826,7 +831,7 @@ mod tests {
826831
);
827832
let mut next = test_build_state("ModuleA", test_module("src/ModuleARenamed.res", None, None, None));
828833

829-
carry_forward_compile_warnings(&previous, &mut next);
834+
carry_forward_compile_state(&previous, &mut next);
830835

831836
let module = next.get_module("ModuleA").expect("module should exist");
832837
let SourceType::SourceFile(source_file) = &module.source_type else {
@@ -853,7 +858,7 @@ mod tests {
853858
test_module("src/ModuleA.res", None, Some("src/ModuleA.resi"), None),
854859
);
855860

856-
carry_forward_compile_warnings(&previous, &mut next);
861+
carry_forward_compile_state(&previous, &mut next);
857862

858863
let module = next.get_module("ModuleA").expect("module should exist");
859864
let SourceType::SourceFile(source_file) = &module.source_type else {
@@ -864,4 +869,24 @@ mod tests {
864869
assert_eq!(interface.compile_warnings.as_deref(), Some("warning: interface"));
865870
assert_eq!(interface.compile_state, CompileState::Warning);
866871
}
872+
873+
#[test]
874+
fn carries_forward_blocked_dirtiness_only_for_matching_sources() {
875+
let mut previous = test_build_state("ModuleA", test_module("src/ModuleA.res", None, None, None));
876+
previous
877+
.build_state
878+
.modules
879+
.get_mut("ModuleA")
880+
.unwrap()
881+
.compile_dirty = true;
882+
883+
let mut same_source = test_build_state("ModuleA", test_module("src/ModuleA.res", None, None, None));
884+
carry_forward_compile_state(&previous, &mut same_source);
885+
assert!(same_source.get_module("ModuleA").unwrap().compile_dirty);
886+
887+
let mut different_source =
888+
test_build_state("ModuleA", test_module("src/Other.res", None, None, None));
889+
carry_forward_compile_state(&previous, &mut different_source);
890+
assert!(!different_source.get_module("ModuleA").unwrap().compile_dirty);
891+
}
867892
}

‎rewatch/tests/clean/01-clean-single-project.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ other_project_compiled_files=$(find packages/new-namespace -type f -name '*.mjs'
4242
if [ "$other_project_compiled_files" -gt 0 ];
4343
then
4444
success "Didn't clean other project files"
45-
git restore .
45+
git restore --worktree -- .
4646
else
4747
error "Expected files from new-namespace not to be cleaned"
4848
exit 1

‎rewatch/tests/clean/02-clean-dev-dependencies.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ project_compiled_files=$(find packages/pure-dev -type f -name '*.mjs' | wc -l |
3131
if [ "$project_compiled_files" -eq 0 ];
3232
then
3333
success "pure-dev cleaned"
34-
git restore .
34+
git restore --worktree -- .
3535
else
3636
error "Expected 0 .mjs files in pure-dev after clean, got $project_compiled_files"
3737
printf "%s\n" "$error_output"

‎rewatch/tests/clean/03-clean-node-modules.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ compiler_assets=$(find node_modules/rescript-nodejs/lib/ocaml -type f -name '*.*
3131
if [ $compiler_assets -eq 0 ];
3232
then
3333
success "compiler assets from node_modules cleaned"
34-
git restore .
34+
git restore --worktree -- .
3535
else
3636
error "Expected 0 files in node_modules/rescript-nodejs/lib/ocaml after clean, got $compiler_assets"
3737
printf "%s\n" "$error_output"

‎rewatch/tests/clean/04-clean-rebuild-no-compiler-update.sh‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,25 @@ else
1818
exit 1
1919
fi
2020

21-
# Rebuild with snapshot output
22-
snapshot_file=../tests/snapshots/clean-rebuild.txt
23-
rewatch build &> $snapshot_file
21+
# Rebuild with captured output. This test checks one lifecycle message; writing
22+
# into a tracked snapshot made the fixture dirty whenever work counts changed.
23+
output_file=$(mktemp "${TMPDIR:-/tmp}/rewatch-clean-rebuild.XXXXXX")
24+
trap 'rm -f "$output_file"' EXIT
25+
rewatch build &> "$output_file"
2426
build_status=$?
25-
normalize_paths $snapshot_file
2627
if [ $build_status -eq 0 ];
2728
then
2829
success "Repo Built"
2930
else
3031
error "Error Building Repo"
31-
cat $snapshot_file >&2
32+
cat "$output_file" >&2
3233
exit 1
3334
fi
3435

3536
# Verify the undesired message is NOT present
36-
if grep -q "Cleaned previous build due to compiler update" $snapshot_file; then
37+
if grep -q "Cleaned previous build due to compiler update" "$output_file"; then
3738
error "Unexpected compiler-update clean message present in rebuild logs"
38-
cat $snapshot_file >&2
39+
cat "$output_file" >&2
3940
exit 1
4041
else
4142
success "No compiler-update clean message present after explicit clean"

‎rewatch/tests/compile/08-remove-file.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ rewatch build &> /dev/null
1111
rm packages/dep02/src/Dep02.res
1212
rewatch build &> ../tests/snapshots/remove-file.txt
1313
normalize_paths ../tests/snapshots/remove-file.txt
14-
git checkout -- packages/dep02/src/Dep02.res
14+
git restore --worktree -- packages/dep02/src/Dep02.res
1515

1616
rewatch build &> /dev/null
1717

‎rewatch/tests/compile/09-dependency-cycle.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ rewatch build &> /dev/null
1111
echo 'Dep01.log()' >> packages/new-namespace/src/NS_alias.res
1212
rewatch build &> ../tests/snapshots/dependency-cycle.txt
1313
normalize_paths ../tests/snapshots/dependency-cycle.txt
14-
git checkout -- packages/new-namespace/src/NS_alias.res
14+
git restore --worktree -- packages/new-namespace/src/NS_alias.res
1515

1616
rewatch build &> /dev/null
1717

0 commit comments

Comments
 (0)