1
1
use clap:: Parser ;
2
+ use command_group:: stdlib:: CommandGroup ;
2
3
use fmterr:: fmt_err;
3
- use notify:: { watcher , RecursiveMode , Watcher } ;
4
- use perseus_cli:: parse:: SnoopSubcommand ;
4
+ use notify:: { recommended_watcher , RecursiveMode , Watcher } ;
5
+ use perseus_cli:: parse:: { ExportOpts , ServeOpts , SnoopSubcommand } ;
5
6
use perseus_cli:: {
6
7
build, check_env, delete_artifacts, delete_bad_dir, deploy, eject, export, has_ejected,
7
8
parse:: { Opts , Subcommand } ,
@@ -11,8 +12,8 @@ use perseus_cli::{errors::*, snoop_build, snoop_server, snoop_wasm_build};
11
12
use std:: env;
12
13
use std:: io:: Write ;
13
14
use std:: path:: PathBuf ;
15
+ use std:: process:: Command ;
14
16
use std:: sync:: mpsc:: channel;
15
- use std:: time:: Duration ;
16
17
17
18
// All this does is run the program and terminate with the acquired exit code
18
19
#[ tokio:: main]
@@ -62,6 +63,14 @@ async fn real_main() -> i32 {
62
63
}
63
64
}
64
65
66
+ // This is used internally for message passing
67
+ enum Event {
68
+ // Sent if we should restart the child process
69
+ Reload ,
70
+ // Sent if we should temrinate the child process
71
+ Terminate ,
72
+ }
73
+
65
74
// This performs the actual logic, separated for deduplication of error handling and destructor control
66
75
// This returns the exit code of the executed command, which we should return from the process itself
67
76
// This prints warnings using the `writeln!` macro, which allows the parsing of `stdout` in production or a vector in testing
@@ -80,6 +89,109 @@ async fn core(dir: PathBuf) -> Result<i32, Error> {
80
89
// Check the user's environment to make sure they have prerequisites
81
90
// We do this after any help pages or version numbers have been parsed for snappiness
82
91
check_env ( ) ?;
92
+
93
+ // Check if this process is allowed to watch for changes
94
+ // This will be set to `true` if this is a child process
95
+ // The CLI will actually spawn another version of itself if we're watching for changes
96
+ // The reason for this is to avoid having to manage handlers for multiple threads and other child processes
97
+ // After several days of attempting, this is the only feasible solution (short of a full rewrite of the CLI)
98
+ let watch_allowed = env:: var ( "PERSEUS_WATCHING_PROHIBITED" ) . is_err ( ) ;
99
+ // Check if the user wants to watch for changes
100
+ match & opts. subcmd {
101
+ Subcommand :: Export ( ExportOpts { watch, .. } )
102
+ | Subcommand :: Serve ( ServeOpts { watch, .. } )
103
+ if * watch && watch_allowed =>
104
+ {
105
+ let ( tx_term, rx) = channel ( ) ;
106
+ let tx_fs = tx_term. clone ( ) ;
107
+ // Set the handler for termination events (more than just SIGINT) on all platforms
108
+ // We do this before anything else so that, if it fails, we don't have servers left open
109
+ ctrlc:: set_handler ( move || {
110
+ tx_term
111
+ . send ( Event :: Terminate )
112
+ . expect ( "couldn't shut down child processes (servers may have been left open)" )
113
+ } )
114
+ . expect ( "couldn't set handlers to gracefully terminate process" ) ;
115
+
116
+ // Find out where this binary is
117
+ // SECURITY: If the CLI were installed with root privileges, it would be possible to create a hard link to the
118
+ // binary, execute through that, and then replace it with a malicious binary before we got here which would
119
+ // allow privilege escalation. See https://vulners.com/securityvulns/SECURITYVULNS:DOC:22183.
120
+ // TODO Drop root privileges at startup
121
+ let bin_name =
122
+ env:: current_exe ( ) . map_err ( |err| WatchError :: GetSelfPathFailed { source : err } ) ?;
123
+ // Get the arguments to provide
124
+ // These are the same, but we'll disallow watching with an environment variable
125
+ let mut args = env:: args ( ) . collect :: < Vec < String > > ( ) ;
126
+ // We'll remove the first element of the arguments (binary name, but less reliable)
127
+ args. remove ( 0 ) ;
128
+
129
+ // Set up a watcher
130
+ let mut watcher = recommended_watcher ( move |_| {
131
+ // If this fails, the watcher channel was completely disconnected, which should never happen (it's in a loop)
132
+ tx_fs. send ( Event :: Reload ) . unwrap ( ) ;
133
+ } )
134
+ . map_err ( |err| WatchError :: WatcherSetupFailed { source : err } ) ?;
135
+ // Watch the current directory
136
+ for entry in std:: fs:: read_dir ( "." )
137
+ . map_err ( |err| WatchError :: ReadCurrentDirFailed { source : err } ) ?
138
+ {
139
+ // We want to exclude `target/` and `.perseus/`, otherwise we should watch everything
140
+ let entry = entry. map_err ( |err| WatchError :: ReadDirEntryFailed { source : err } ) ?;
141
+ let name = entry. file_name ( ) ;
142
+ if name != "target" && name != ".perseus" {
143
+ watcher
144
+ . watch ( & entry. path ( ) , RecursiveMode :: Recursive )
145
+ . map_err ( |err| WatchError :: WatchFileFailed {
146
+ filename : entry. path ( ) . to_str ( ) . unwrap ( ) . to_string ( ) ,
147
+ source : err,
148
+ } ) ?;
149
+ }
150
+ }
151
+
152
+ // This will store the handle to the child process
153
+ // This will be updated every time we re-create the process
154
+ // We spawn it as a process group, whcih means signals go to grandchild processes as well, which means hot reloading
155
+ // can actually work!
156
+ let mut child = Command :: new ( & bin_name)
157
+ . args ( & args)
158
+ . env ( "PERSEUS_WATCHING_PROHIBITED" , "true" )
159
+ . group_spawn ( )
160
+ . map_err ( |err| WatchError :: SpawnSelfFailed { source : err } ) ?;
161
+
162
+ let res = loop {
163
+ match rx. recv ( ) {
164
+ Ok ( Event :: Reload ) => {
165
+ // Kill the current child process
166
+ // This will return an error if the child has already exited, which is fine
167
+ // This gracefully kills the process in the sense that it kills it and all its children
168
+ let _ = child. kill ( ) ;
169
+ // Restart it
170
+ child = Command :: new ( & bin_name)
171
+ . args ( & args)
172
+ . env ( "PERSEUS_WATCHING_PROHIBITED" , "true" )
173
+ . group_spawn ( )
174
+ . map_err ( |err| WatchError :: SpawnSelfFailed { source : err } ) ?;
175
+ }
176
+ Ok ( Event :: Terminate ) => {
177
+ // This means the user is trying to stop the process
178
+ // We have to manually terminate the process group, because it's a process *group*
179
+ let _ = child. kill ( ) ;
180
+ // From here, we can let the prgoram terminate naturally
181
+ break Ok ( 0 ) ;
182
+ }
183
+ Err ( err) => break Err ( WatchError :: WatcherError { source : err } ) ,
184
+ }
185
+ } ;
186
+ let exit_code = res?;
187
+ Ok ( exit_code)
188
+ }
189
+ // If not, just run the central logic normally
190
+ _ => core_watch ( dir, opts) . await ,
191
+ }
192
+ }
193
+
194
+ async fn core_watch ( dir : PathBuf , opts : Opts ) -> Result < i32 , Error > {
83
195
// If we're not cleaning up artifacts, create them if needed
84
196
if !matches ! ( opts. subcmd, Subcommand :: Clean ( _) ) {
85
197
prepare ( dir. clone ( ) ) ?;
@@ -98,141 +210,17 @@ async fn core(dir: PathBuf) -> Result<i32, Error> {
98
210
if exit_code != 0 {
99
211
return Ok ( exit_code) ;
100
212
}
101
-
102
- if export_opts. watch {
103
- let dir_2 = dir. clone ( ) ;
104
- let export_opts_2 = export_opts. clone ( ) ;
105
- if export_opts. serve {
106
- tokio:: spawn ( async move {
107
- serve_exported ( dir_2, export_opts_2. host , export_opts_2. port ) . await
108
- } ) ;
109
- }
110
- // Now watch for changes
111
- let ( tx, rx) = channel ( ) ;
112
- let mut watcher = watcher ( tx, Duration :: from_secs ( 2 ) )
113
- . map_err ( |err| WatchError :: WatcherSetupFailed { source : err } ) ?;
114
- // Watch the current directory
115
- for entry in std:: fs:: read_dir ( "." )
116
- . map_err ( |err| WatchError :: ReadCurrentDirFailed { source : err } ) ?
117
- {
118
- // We want to exclude `target/` and `.perseus/`, otherwise we should watch everything
119
- let entry =
120
- entry. map_err ( |err| WatchError :: ReadDirEntryFailed { source : err } ) ?;
121
- let name = entry. file_name ( ) ;
122
- if name != "target" && name != ".perseus" {
123
- watcher
124
- . watch ( entry. path ( ) , RecursiveMode :: Recursive )
125
- . map_err ( |err| WatchError :: WatchFileFailed {
126
- filename : entry. path ( ) . to_str ( ) . unwrap ( ) . to_string ( ) ,
127
- source : err,
128
- } ) ?;
129
- }
130
- }
131
-
132
- let res: Result < i32 , Error > = loop {
133
- match rx. recv ( ) {
134
- Ok ( _) => {
135
- // Delete old build/exportation artifacts
136
- delete_artifacts ( dir. clone ( ) , "static" ) ?;
137
- delete_artifacts ( dir. clone ( ) , "exported" ) ?;
138
- let dir_2 = dir. clone ( ) ;
139
- let opts = export_opts. clone ( ) ;
140
- match export ( dir_2. clone ( ) , opts. clone ( ) ) {
141
- // We'l let the user know if there's a non-zero exit code
142
- Ok ( exit_code) => {
143
- if exit_code != 0 {
144
- eprintln ! ( "Non-zero exit code returned from exporting process: {}." , exit_code)
145
- }
146
- }
147
- // Because we're watching for changes, we can manage errors here
148
- // We won't actually terminate unless the user tells us to
149
- Err ( err) => eprintln ! ( "{}" , fmt_err( & err) ) ,
150
- }
151
- // TODO Reload the browser automatically
152
- }
153
- Err ( err) => break Err ( WatchError :: WatcherError { source : err } . into ( ) ) ,
154
- }
155
- } ;
156
- return res;
157
- } else {
158
- if export_opts. serve {
159
- serve_exported ( dir, export_opts. host , export_opts. port ) . await ;
160
- }
161
- 0
213
+ if export_opts. serve {
214
+ serve_exported ( dir, export_opts. host , export_opts. port ) . await ;
162
215
}
216
+ 0
163
217
}
164
218
Subcommand :: Serve ( serve_opts) => {
165
219
if !serve_opts. no_build {
166
220
delete_artifacts ( dir. clone ( ) , "static" ) ?;
167
221
}
168
- if serve_opts. watch {
169
- match serve ( dir. clone ( ) , serve_opts. clone ( ) ) {
170
- // We'll let the user know if there's a non-zero exit code
171
- Ok ( ( exit_code, _server_path) ) => {
172
- if exit_code != 0 {
173
- eprintln ! (
174
- "Non-zero exit code returned from serving process: {}." ,
175
- exit_code
176
- )
177
- }
178
- }
179
- // Because we're watching for changes, we can manage errors here
180
- // We won't actually terminate unless the user tells us to
181
- Err ( err) => eprintln ! ( "{}" , fmt_err( & err) ) ,
182
- } ;
183
- // Now watch for changes
184
- let ( tx, rx) = channel ( ) ;
185
- let mut watcher = watcher ( tx, Duration :: from_secs ( 2 ) )
186
- . map_err ( |err| WatchError :: WatcherSetupFailed { source : err } ) ?;
187
- // Watch the current directory
188
- for entry in std:: fs:: read_dir ( "." )
189
- . map_err ( |err| WatchError :: ReadCurrentDirFailed { source : err } ) ?
190
- {
191
- // We want to exclude `target/` and `.perseus/`, otherwise we should watch everything
192
- let entry =
193
- entry. map_err ( |err| WatchError :: ReadDirEntryFailed { source : err } ) ?;
194
- let name = entry. file_name ( ) ;
195
- if name != "target" && name != ".perseus" {
196
- watcher
197
- . watch ( entry. path ( ) , RecursiveMode :: Recursive )
198
- . map_err ( |err| WatchError :: WatchFileFailed {
199
- filename : entry. path ( ) . to_str ( ) . unwrap ( ) . to_string ( ) ,
200
- source : err,
201
- } ) ?;
202
- }
203
- }
204
-
205
- let res: Result < i32 , Error > = loop {
206
- match rx. recv ( ) {
207
- Ok ( _) => {
208
- // Delete old build artifacts if `--no-build` wasn't specified
209
- if !serve_opts. no_build {
210
- delete_artifacts ( dir. clone ( ) , "static" ) ?;
211
- }
212
- match serve ( dir. clone ( ) , serve_opts. clone ( ) ) {
213
- // We'll let the user know if there's a non-zero exit code
214
- Ok ( ( exit_code, _server_path) ) => {
215
- if exit_code != 0 {
216
- eprintln ! (
217
- "Non-zero exit code returned from serving process: {}." ,
218
- exit_code
219
- )
220
- }
221
- }
222
- // Because we're watching for changes, we can manage errors here
223
- // We won't actually terminate unless the user tells us to
224
- Err ( err) => eprintln ! ( "{}" , fmt_err( & err) ) ,
225
- } ;
226
- // TODO Reload the browser automatically
227
- }
228
- Err ( err) => break Err ( WatchError :: WatcherError { source : err } . into ( ) ) ,
229
- }
230
- } ;
231
- return res;
232
- } else {
233
- let ( exit_code, _server_path) = serve ( dir, serve_opts) ?;
234
- exit_code
235
- }
222
+ let ( exit_code, _server_path) = serve ( dir, serve_opts) ?;
223
+ exit_code
236
224
}
237
225
Subcommand :: Test ( test_opts) => {
238
226
// This will be used by the subcrates
0 commit comments