1 | module main |
2 | |
3 | import os |
4 | import time |
5 | import term |
6 | import flag |
7 | |
8 | const scan_timeout_s = get_scan_timeout_seconds() |
9 | |
10 | const max_v_cycles = 1000 |
11 | |
12 | const scan_frequency_hz = 4 |
13 | |
14 | const scan_period_ms = 1000 / scan_frequency_hz |
15 | |
16 | const max_scan_cycles = scan_timeout_s * scan_frequency_hz |
17 | |
18 | fn get_scan_timeout_seconds() int { |
19 | env_vw_timeout := os.getenv('VWATCH_TIMEOUT').int() |
20 | if env_vw_timeout == 0 { |
21 | $if gcboehm ? { |
22 | return 35000000 // over 1 year |
23 | } $else { |
24 | return 5 * 60 |
25 | } |
26 | } |
27 | return env_vw_timeout |
28 | } |
29 | |
30 | // |
31 | // Implements `v watch file.v` , `v watch run file.v` etc. |
32 | // With this command, V will collect all .v files that are needed for the |
33 | // compilation, then it will enter an infinite loop, monitoring them for |
34 | // changes. |
35 | // |
36 | // When a change is detected, it will stop the current process, if it is |
37 | // still running, then rerun/recompile/etc. |
38 | // |
39 | // In effect, this makes it easy to have an editor session and a separate |
40 | // terminal, running just `v watch run file.v`, and you will see your |
41 | // changes right after you save your .v file in your editor. |
42 | // |
43 | // |
44 | // Since -gc boehm is not available on all platforms yet, |
45 | // and this program leaks ~8MB/minute without it, the implementation here |
46 | // is done similarly to vfmt in 2 modes, in the same executable: |
47 | // |
48 | // a) A parent/manager process that only manages a single worker |
49 | // process. The parent process does mostly nothing except restarting |
50 | // workers, thus it does not leak much. |
51 | // |
52 | // b) A worker process, doing the actual monitoring/polling. |
53 | // Note: *workers are started with the --vwatchworker option* |
54 | // |
55 | // Worker processes will run for a limited number of iterations, then |
56 | // they will do exit(255), and then the parent will start a new worker. |
57 | // Exiting by any other code will cause the parent to also exit with the |
58 | // same error code. This limits the potential leak that a worker process |
59 | // can do, even without using the garbage collection mode. |
60 | // |
61 | |
62 | struct VFileStat { |
63 | path string |
64 | mtime i64 |
65 | } |
66 | |
67 | [unsafe] |
68 | fn (mut vfs VFileStat) free() { |
69 | unsafe { vfs.path.free() } |
70 | } |
71 | |
72 | enum RerunCommand { |
73 | restart |
74 | quit |
75 | } |
76 | |
77 | struct Context { |
78 | mut: |
79 | pid int // the pid of the current process; useful while debugging manager/worker interactions |
80 | is_worker bool // true in the workers, false in the manager process |
81 | check_period_ms int = scan_period_ms |
82 | vexe string |
83 | affected_paths []string |
84 | vfiles []VFileStat |
85 | opts []string |
86 | rerun_channel chan RerunCommand |
87 | child_process &os.Process = unsafe { nil } |
88 | is_exiting bool // set by SIGINT/Ctrl-C |
89 | v_cycles int // how many times the worker has restarted the V compiler |
90 | scan_cycles int // how many times the worker has scanned for source file changes |
91 | clear_terminal bool // whether to clear the terminal before each re-run |
92 | keep_running bool // when true, re-run the program automatically if it exits on its own. Useful for gg apps. |
93 | silent bool // when true, watch will not print a timestamp line before each re-run |
94 | add_files []string // path to additional files that have to be watched for changes |
95 | ignore_exts []string // extensions of files that will be ignored, even if they change (useful for sqlite.db files for example) |
96 | cmd_before_run string // a command to run before each re-run |
97 | cmd_after_run string // a command to run after each re-run |
98 | only_watch []string // If not empty, *all* files that trigger updates, should match *at least one* of these s.match_glob() patterns. This is also triggered for vweb apps, to monitor for just *.v,*.js,*.css,*.html in vweb projects. |
99 | } |
100 | |
101 | [if debug_vwatch ?] |
102 | fn (mut context Context) elog(msg string) { |
103 | eprintln('> vwatch ${context.pid}, ${msg}') |
104 | } |
105 | |
106 | fn (context &Context) str() string { |
107 | return 'Context{ pid: ${context.pid}, is_worker: ${context.is_worker}, check_period_ms: ${context.check_period_ms}, vexe: ${context.vexe}, opts: ${context.opts}, is_exiting: ${context.is_exiting}, vfiles: ${context.vfiles}' |
108 | } |
109 | |
110 | fn (mut context Context) is_ext_ignored(pf string, pf_ext string) bool { |
111 | for ipattern in context.ignore_exts { |
112 | if pf_ext.match_glob(ipattern) { |
113 | return true |
114 | } |
115 | } |
116 | if pf_ext in ['', '.so', '.a'] { |
117 | // on unix, the executables saved by compilers, usually do not have extensions at all, and shared libs are .so |
118 | return true |
119 | } |
120 | if pf_ext in ['.exe', '.dll', '.def'] { |
121 | // on windows, files with these extensions will be generated by the compiler |
122 | return true |
123 | } |
124 | // ignore common backup files saved by editors like emacs/jed/vim: |
125 | if pf_ext == '.bak' { |
126 | return true |
127 | } |
128 | if pf.starts_with('.#') { |
129 | return true |
130 | } |
131 | if pf.ends_with('~') { |
132 | return true |
133 | } |
134 | return false |
135 | } |
136 | |
137 | fn (mut context Context) get_stats_for_affected_vfiles() []VFileStat { |
138 | if context.affected_paths.len == 0 { |
139 | mut apaths := map[string]bool{} |
140 | // The next command will make V parse the program, and print all .v files, |
141 | // needed for its compilation, without actually compiling it. |
142 | copts := context.opts.join(' ') |
143 | cmd := '"${context.vexe}" -silent -print-watched-files ${copts}' |
144 | // context.elog('> cmd: ${cmd}') |
145 | mut paths := []string{} |
146 | if context.add_files.len > 0 && context.add_files[0] != '' { |
147 | paths << context.add_files |
148 | } |
149 | vfiles := os.execute(cmd) |
150 | if vfiles.exit_code == 0 { |
151 | paths_trimmed := vfiles.output.trim_space() |
152 | reported_used_files := paths_trimmed.split_any('\n') |
153 | $if trace_reported_used_files ? { |
154 | context.elog('reported_used_files: ${reported_used_files}') |
155 | } |
156 | paths << reported_used_files |
157 | } |
158 | mut is_vweb_found := false |
159 | for vf in paths { |
160 | apaths[os.real_path(os.dir(vf))] = true |
161 | if vf.contains('vweb.v') { |
162 | is_vweb_found = true |
163 | } |
164 | } |
165 | |
166 | if is_vweb_found { |
167 | if !os.args.any(it.starts_with('--only-watch')) { |
168 | // vweb is often used with SQLite .db or .sqlite3 files right next to the executable/source, |
169 | // that are updated by the vweb app, causing a restart of the app, which in turn causes the |
170 | // browser to reload the current page, that probably triggered the update in the first place. |
171 | // Note that the problem is not specific to SQLite, any database that stores its files in the |
172 | // current (project) folder, will also cause this. |
173 | println('`v watch` detected that you are compiling a vweb project.') |
174 | println(' Because of that, the `--only-watch=*.v,*.html,*.css,*.js` flag was also implied.') |
175 | println(' In result, `v watch` will ignore changes to other files.') |
176 | println(' Add your own --only-watch filter, if you wish to override that choice.') |
177 | println('') |
178 | context.only_watch = '*.v,*.html,*.css,*.js'.split_any(',') |
179 | } |
180 | } |
181 | context.affected_paths = apaths.keys() |
182 | // context.elog('vfiles paths to be scanned: $context.affected_paths') |
183 | } |
184 | // scan all files in the found folders: |
185 | mut newstats := []VFileStat{} |
186 | for path in context.affected_paths { |
187 | mut files := os.ls(path) or { []string{} } |
188 | next_file: for pf in files { |
189 | pf_path := os.join_path_single(path, pf) |
190 | if context.only_watch.len > 0 { |
191 | // in the whitelist mode, first only allow files, which match at least one of the patterns in context.only_watch: |
192 | mut matched_pattern_idx := -1 |
193 | for ow_pattern_idx, ow_pattern in context.only_watch { |
194 | if pf_path.match_glob(ow_pattern) { |
195 | matched_pattern_idx = ow_pattern_idx |
196 | context.elog('> ${@METHOD} matched --only-watch pattern: ${ow_pattern}, for file: ${pf_path}') |
197 | break |
198 | } |
199 | } |
200 | if matched_pattern_idx == -1 { |
201 | context.elog('> ${@METHOD} --only-watch ignored file: ${pf_path}') |
202 | continue |
203 | } |
204 | } |
205 | // by default allow everything, except very specific extensions (backup files, executables etc): |
206 | pf_ext := os.file_ext(pf).to_lower() |
207 | if context.is_ext_ignored(pf, pf_ext) { |
208 | context.elog('> ${@METHOD} ignored extension: ${pf_ext}, for file: ${pf_path}') |
209 | continue |
210 | } |
211 | f := os.join_path(path, pf) |
212 | fullpath := os.real_path(f) |
213 | mtime := os.file_last_mod_unix(fullpath) |
214 | newstats << VFileStat{fullpath, mtime} |
215 | } |
216 | } |
217 | // always add the v compiler itself, so that if it is recompiled with `v self` |
218 | // the watcher will rerun the compilation too |
219 | newstats << VFileStat{context.vexe, os.file_last_mod_unix(context.vexe)} |
220 | return newstats |
221 | } |
222 | |
223 | fn (mut context Context) get_changed_vfiles() int { |
224 | mut changed := 0 |
225 | newfiles := context.get_stats_for_affected_vfiles() |
226 | for vfs in newfiles { |
227 | mut found := false |
228 | for existing_vfs in context.vfiles { |
229 | if existing_vfs.path == vfs.path { |
230 | found = true |
231 | if existing_vfs.mtime != vfs.mtime { |
232 | context.elog('> new updates for file: ${vfs}') |
233 | changed++ |
234 | } |
235 | break |
236 | } |
237 | } |
238 | if !found { |
239 | changed++ |
240 | continue |
241 | } |
242 | } |
243 | context.vfiles = newfiles |
244 | if changed > 0 { |
245 | context.elog('> get_changed_vfiles: ${changed}') |
246 | } |
247 | return changed |
248 | } |
249 | |
250 | fn change_detection_loop(ocontext &Context) { |
251 | mut context := unsafe { ocontext } |
252 | for { |
253 | if context.v_cycles >= max_v_cycles || context.scan_cycles >= max_scan_cycles { |
254 | context.is_exiting = true |
255 | context.kill_pgroup() |
256 | time.sleep(50 * time.millisecond) |
257 | exit(255) |
258 | } |
259 | if context.is_exiting { |
260 | return |
261 | } |
262 | changes := context.get_changed_vfiles() |
263 | if changes > 0 { |
264 | context.rerun_channel <- RerunCommand.restart |
265 | } |
266 | time.sleep(context.check_period_ms * time.millisecond) |
267 | context.scan_cycles++ |
268 | } |
269 | } |
270 | |
271 | fn (mut context Context) kill_pgroup() { |
272 | if unsafe { context.child_process == 0 } { |
273 | return |
274 | } |
275 | if context.child_process.is_alive() { |
276 | context.child_process.signal_pgkill() |
277 | } |
278 | context.child_process.wait() |
279 | } |
280 | |
281 | fn (mut context Context) run_before_cmd() { |
282 | if context.cmd_before_run != '' { |
283 | context.elog('> run_before_cmd: "${context.cmd_before_run}"') |
284 | os.system(context.cmd_before_run) |
285 | } |
286 | } |
287 | |
288 | fn (mut context Context) run_after_cmd() { |
289 | if context.cmd_after_run != '' { |
290 | context.elog('> run_after_cmd: "${context.cmd_after_run}"') |
291 | os.system(context.cmd_after_run) |
292 | } |
293 | } |
294 | |
295 | fn (mut context Context) compilation_runner_loop() { |
296 | cmd := '"${context.vexe}" ${context.opts.join(' ')}' |
297 | _ := <-context.rerun_channel |
298 | for { |
299 | context.elog('>> loop: v_cycles: ${context.v_cycles}') |
300 | if context.clear_terminal { |
301 | term.clear() |
302 | } |
303 | context.run_before_cmd() |
304 | timestamp := time.now().format_ss_milli() |
305 | context.child_process = os.new_process(context.vexe) |
306 | context.child_process.use_pgroup = true |
307 | context.child_process.set_args(context.opts) |
308 | context.child_process.run() |
309 | if !context.silent { |
310 | eprintln('${timestamp}: ${cmd} | pid: ${context.child_process.pid:7d} | reload cycle: ${context.v_cycles:5d}') |
311 | } |
312 | for { |
313 | mut notalive_count := 0 |
314 | mut cmds := []RerunCommand{} |
315 | for { |
316 | if context.is_exiting { |
317 | return |
318 | } |
319 | if !context.child_process.is_alive() { |
320 | context.child_process.wait() |
321 | notalive_count++ |
322 | if notalive_count == 1 { |
323 | // a short lived process finished, do cleanup: |
324 | context.run_after_cmd() |
325 | if context.keep_running { |
326 | break |
327 | } |
328 | } |
329 | } |
330 | select { |
331 | action := <-context.rerun_channel { |
332 | cmds << action |
333 | if action == .quit { |
334 | context.kill_pgroup() |
335 | return |
336 | } |
337 | } |
338 | 100 * time.millisecond { |
339 | should_restart := RerunCommand.restart in cmds |
340 | cmds = [] |
341 | if should_restart { |
342 | // context.elog('>>>>>>>> KILLING $context.child_process.pid') |
343 | context.kill_pgroup() |
344 | break |
345 | } |
346 | } |
347 | } |
348 | } |
349 | if !context.child_process.is_alive() { |
350 | context.elog('> child_process is no longer alive | notalive_count: ${notalive_count}') |
351 | context.child_process.wait() |
352 | context.child_process.close() |
353 | if notalive_count == 0 { |
354 | // a long running process was killed, do cleanup: |
355 | context.run_after_cmd() |
356 | } |
357 | break |
358 | } |
359 | } |
360 | context.v_cycles++ |
361 | } |
362 | } |
363 | |
364 | const ccontext = Context{ |
365 | child_process: 0 |
366 | } |
367 | |
368 | fn main() { |
369 | mut context := unsafe { &Context(voidptr(&ccontext)) } |
370 | context.pid = os.getpid() |
371 | context.vexe = os.getenv('VEXE') |
372 | |
373 | watch_pos := os.args.index('watch') |
374 | all_args_before_watch_cmd := os.args#[1..watch_pos] |
375 | all_args_after_watch_cmd := os.args#[watch_pos + 1..] |
376 | // dump(os.getpid()) |
377 | // dump(all_args_before_watch_cmd) |
378 | // dump(all_args_after_watch_cmd) |
379 | |
380 | // Options after `run` should be ignored, since they are intended for the user program, not for the watcher. |
381 | // For example, `v watch run x.v -a -b -k', should pass all of -a -b -k to the compiled and run program. |
382 | only_watch_options, has_run := all_before('run', all_args_after_watch_cmd) |
383 | |
384 | mut fp := flag.new_flag_parser(only_watch_options) |
385 | fp.application('v watch') |
386 | fp.version('0.0.2') |
387 | fp.description('Collect all .v files needed for a compilation, then re-run the compilation when any of the source changes.') |
388 | fp.arguments_description('[--silent] [--clear] [--ignore .db] [--add /path/to/a/file.v] [run] program.v') |
389 | fp.allow_unknown_args() |
390 | |
391 | context.is_worker = fp.bool('vwatchworker', 0, false, 'Internal flag. Used to distinguish vwatch manager and worker processes.') |
392 | context.silent = fp.bool('silent', `s`, false, 'Be more silent; do not print the watch timestamp before each re-run.') |
393 | context.clear_terminal = fp.bool('clear', `c`, false, 'Clears the terminal before each re-run.') |
394 | context.keep_running = fp.bool('keep', `k`, false, 'Keep the program running. Restart it automatically, if it exits by itself. Useful for gg/ui apps.') |
395 | context.add_files = fp.string('add', `a`, '', 'Add more files to be watched. Useful with `v watch --add=/tmp/feature.v run cmd/v /tmp/feature.v`, if you change *both* the compiler, and the feature.v file.').split_any(',') |
396 | context.ignore_exts = fp.string('ignore', `i`, '', 'Ignore files having these extensions. Useful with `v watch --ignore=.db run server.v`, if your server writes to an sqlite.db file in the same folder.').split_any(',') |
397 | context.only_watch = fp.string('only-watch', `o`, '', 'Watch only files matching these globe patterns. Example for a markdown renderer project: `v watch --only-watch=*.v,*.md run .`').split_any(',') |
398 | show_help := fp.bool('help', `h`, false, 'Show this help screen.') |
399 | context.cmd_before_run = fp.string('before', 0, '', 'A command to execute *before* each re-run.') |
400 | context.cmd_after_run = fp.string('after', 0, '', 'A command to execute *after* each re-run.') |
401 | if show_help { |
402 | println(fp.usage()) |
403 | exit(0) |
404 | } |
405 | remaining_options := fp.finalize() or { |
406 | eprintln('Error: ${err}') |
407 | exit(1) |
408 | } |
409 | context.opts = [] |
410 | context.opts << all_args_before_watch_cmd |
411 | context.opts << remaining_options |
412 | if has_run { |
413 | context.opts << 'run' |
414 | context.opts << all_after('run', all_args_after_watch_cmd) |
415 | } |
416 | context.elog('>>> context.pid: ${context.pid}') |
417 | context.elog('>>> context.vexe: ${context.vexe}') |
418 | context.elog('>>> context.opts: ${context.opts}') |
419 | context.elog('>>> context.is_worker: ${context.is_worker}') |
420 | context.elog('>>> context.clear_terminal: ${context.clear_terminal}') |
421 | context.elog('>>> context.add_files: ${context.add_files}') |
422 | context.elog('>>> context.ignore_exts: ${context.ignore_exts}') |
423 | context.elog('>>> context.only_watch: ${context.only_watch}') |
424 | if context.is_worker { |
425 | context.worker_main() |
426 | } else { |
427 | context.manager_main(all_args_before_watch_cmd, all_args_after_watch_cmd) |
428 | } |
429 | } |
430 | |
431 | fn (mut context Context) manager_main(all_args_before_watch_cmd []string, all_args_after_watch_cmd []string) { |
432 | myexecutable := os.executable() |
433 | mut worker_opts := all_args_before_watch_cmd.clone() |
434 | worker_opts << ['watch', '--vwatchworker'] |
435 | worker_opts << all_args_after_watch_cmd |
436 | for { |
437 | mut worker_process := os.new_process(myexecutable) |
438 | worker_process.set_args(worker_opts) |
439 | worker_process.run() |
440 | for { |
441 | if !worker_process.is_alive() { |
442 | worker_process.wait() |
443 | break |
444 | } |
445 | time.sleep(200 * time.millisecond) |
446 | } |
447 | if !(worker_process.code == 255 && worker_process.status == .exited) { |
448 | worker_process.close() |
449 | break |
450 | } |
451 | worker_process.close() |
452 | } |
453 | } |
454 | |
455 | fn (mut context Context) worker_main() { |
456 | context.rerun_channel = chan RerunCommand{cap: 10} |
457 | os.signal_opt(.int, fn (_ os.Signal) { |
458 | mut context := unsafe { &Context(voidptr(&ccontext)) } |
459 | context.is_exiting = true |
460 | context.kill_pgroup() |
461 | }) or { panic(err) } |
462 | spawn context.compilation_runner_loop() |
463 | change_detection_loop(context) |
464 | } |
465 | |
466 | fn all_before(needle string, all []string) ([]string, bool) { |
467 | needle_pos := all.index(needle) |
468 | if needle_pos == -1 { |
469 | return all, false |
470 | } |
471 | return all#[..needle_pos], true |
472 | } |
473 | |
474 | fn all_after(needle string, all []string) []string { |
475 | needle_pos := all.index(needle) |
476 | if needle_pos == -1 { |
477 | return all |
478 | } |
479 | return all#[needle_pos + 1..] |
480 | } |