v / cmd / tools
Raw file | 480 loc (447 sloc) | 11.99 KB | Latest commit hash 017ace6ea
1module main
2
3import os
4import time
5import term
6import flag
7
8const scan_timeout_s = get_scan_timeout_seconds()
9
10const max_v_cycles = 1000
11
12const scan_frequency_hz = 4
13
14const scan_period_ms = 1000 / scan_frequency_hz
15
16const max_scan_cycles = scan_timeout_s * scan_frequency_hz
17
18fn 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
62struct VFileStat {
63 path string
64 mtime i64
65}
66
67[unsafe]
68fn (mut vfs VFileStat) free() {
69 unsafe { vfs.path.free() }
70}
71
72enum RerunCommand {
73 restart
74 quit
75}
76
77struct Context {
78mut:
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 ?]
102fn (mut context Context) elog(msg string) {
103 eprintln('> vwatch ${context.pid}, ${msg}')
104}
105
106fn (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
110fn (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
137fn (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
223fn (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
250fn 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
271fn (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
281fn (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
288fn (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
295fn (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
364const ccontext = Context{
365 child_process: 0
366}
367
368fn 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
431fn (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
455fn (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
466fn 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
474fn 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}