]> git.argeo.org Git - lgpl/argeo-commons.git/blob - org.argeo.cms.jshell/src/org/argeo/cms/jshell/JShellClient.java
Small improvements to JShell
[lgpl/argeo-commons.git] / org.argeo.cms.jshell / src / org / argeo / cms / jshell / JShellClient.java
1 package org.argeo.cms.jshell;
2
3 import static java.lang.System.Logger.Level.ERROR;
4 import static java.lang.System.Logger.Level.TRACE;
5 import static java.net.StandardProtocolFamily.UNIX;
6 import static java.nio.charset.StandardCharsets.UTF_8;
7
8 import java.io.BufferedReader;
9 import java.io.File;
10 import java.io.IOException;
11 import java.io.InputStream;
12 import java.io.InputStreamReader;
13 import java.io.OutputStream;
14 import java.lang.System.Logger;
15 import java.lang.management.ManagementFactory;
16 import java.net.StandardSocketOptions;
17 import java.net.UnixDomainSocketAddress;
18 import java.nio.ByteBuffer;
19 import java.nio.channels.AsynchronousCloseException;
20 import java.nio.channels.Channels;
21 import java.nio.channels.ClosedByInterruptException;
22 import java.nio.channels.ReadableByteChannel;
23 import java.nio.channels.SocketChannel;
24 import java.nio.channels.WritableByteChannel;
25 import java.nio.file.Files;
26 import java.nio.file.Path;
27 import java.nio.file.Paths;
28 import java.util.ArrayList;
29 import java.util.HashMap;
30 import java.util.List;
31 import java.util.Map;
32 import java.util.UUID;
33
34 /** A JShell client to a local CMS node. */
35 public class JShellClient {
36 private final static Logger logger = System.getLogger(JShellClient.class.getName());
37
38 public final static String STD = "std";
39 public final static String CTL = "ctl";
40
41 public final static String JSH = "jsh";
42 public final static String JTERM = "jterm";
43
44 private static String sttyExec = "/usr/bin/stty";
45
46 /** Benchmark based on uptime. */
47 private static boolean benchmark = false;
48
49 /**
50 * The real path (following symbolic links) to the directory were to create
51 * sessions.
52 */
53 private Path localBase;
54
55 /** The symbolic name of the bundle from which to run. */
56 private String symbolicName;
57
58 /** The script to run. */
59 private Path script;
60 /** Additional arguments of the script */
61 private List<String> scriptArgs;
62
63 private String ttyConfig;
64 private boolean terminal;
65
66 /** Workaround to be able to test in Eclipse console */
67 private boolean inEclipse = false;
68
69 public JShellClient(Path targetStateDirectory, String symbolicName, Path script, List<String> scriptArgs) {
70 try {
71 this.terminal = System.console() != null && script == null;
72 if (inEclipse && script == null)
73 terminal = true;
74 if (terminal) {
75 localBase = targetStateDirectory.resolve(JTERM);
76 } else {
77 localBase = targetStateDirectory.resolve(JSH);
78 }
79 if (Files.isSymbolicLink(localBase)) {
80 localBase = localBase.toRealPath();
81 }
82 this.symbolicName = symbolicName;
83 this.script = script;
84 this.scriptArgs = scriptArgs == null ? new ArrayList<>() : scriptArgs;
85 } catch (IOException e) {
86 throw new IllegalStateException("Cannot initialise client", e);
87 }
88 }
89
90 public void run() {
91 try {
92 if (terminal)
93 toRawTerminal();
94 SocketPipeSource std = new SocketPipeSource(STD, script != null);
95 std.setInputStream(System.in);
96 std.setOutputStream(System.out);
97
98 SocketPipeSource ctl = new SocketPipeSource(CTL, false);
99 ctl.setOutputStream(System.err);
100
101 Runtime.getRuntime().addShutdownHook(new Thread(() -> {
102 // System.out.println("\nShutting down...");
103 toOriginalTerminal();
104 std.shutdown();
105 ctl.shutdown();
106 }, "Shut down JShell client"));
107
108 Path bundleSnDir = localBase.resolve(symbolicName);
109 if (!Files.exists(bundleSnDir))
110 Files.createDirectory(bundleSnDir);
111 UUID uuid = UUID.randomUUID();
112 Path sessionDir = bundleSnDir.resolve(uuid.toString());
113
114 // creating the directory will trigger opening of the session on server side
115 Files.createDirectory(sessionDir);
116
117 Path stdPath = sessionDir.resolve(JShellClient.STD);
118 Path ctlPath = sessionDir.resolve(JShellClient.CTL);
119
120 while (!(Files.exists(stdPath) && Files.exists(ctlPath))) {
121 // TODO timeout
122 try {
123 Thread.sleep(1);
124 } catch (InterruptedException e) {
125 // silent
126 }
127 }
128
129 UnixDomainSocketAddress stdSocketAddress = UnixDomainSocketAddress.of(stdPath.toRealPath());
130 UnixDomainSocketAddress ctlSocketAddress = UnixDomainSocketAddress.of(ctlPath.toRealPath());
131
132 try (SocketChannel stdChannel = SocketChannel.open(UNIX);
133 SocketChannel ctlChannel = SocketChannel.open(UNIX);) {
134 ctlChannel.connect(ctlSocketAddress);
135 ctl.process(ctlChannel);
136 if (script != null) {
137 new ScriptThread(ctlChannel).start();
138 }
139 stdChannel.connect(stdSocketAddress);
140 std.process(stdChannel);
141
142 while (!std.isCompleted() && !ctl.isCompleted()) {
143 // isCompleted() will block
144 }
145 }
146 if (benchmark)
147 System.err.println(ManagementFactory.getRuntimeMXBean().getUptime());
148 std.shutdown();
149 ctl.shutdown();
150 } catch (IOException e) {
151 e.printStackTrace();
152 } finally {
153 toOriginalTerminal();
154 }
155
156 }
157
158 public static void main(String[] args) throws IOException, InterruptedException {
159 if (benchmark)
160 System.err.println(ManagementFactory.getRuntimeMXBean().getUptime());
161 List<String> plainArgs = new ArrayList<>();
162 Map<String, List<String>> options = new HashMap<>();
163 String currentOption = null;
164 for (int i = 0; i < args.length; i++) {
165 if (args[i].startsWith("-")) {
166 currentOption = args[i];
167 if (!options.containsKey(currentOption))
168 options.put(currentOption, new ArrayList<>());
169 i++;
170 options.get(currentOption).add(args[i]);
171 } else {
172 plainArgs.add(args[i]);
173 }
174 }
175
176 Path targetStateDirectory = Paths.get(options.get("-d").get(0));
177 String symbolicName = options.get("-b").get(0);
178
179 Path script = plainArgs.isEmpty() ? null : Paths.get(plainArgs.get(0));
180 List<String> scriptArgs = new ArrayList<>();
181 for (int i = 1; i < plainArgs.size(); i++)
182 scriptArgs.add(plainArgs.get(i));
183
184 JShellClient client = new JShellClient(targetStateDirectory, symbolicName, script, scriptArgs);
185 client.run();
186 }
187
188 /*
189 * TERMINAL
190 */
191 /** Set the terminal to raw mode. */
192 protected synchronized void toRawTerminal() {
193 boolean isWindows = File.separatorChar == '\\';
194 if (isWindows)
195 return;
196 if (inEclipse)
197 return;
198 // save current configuration
199 ttyConfig = stty("-g");
200 if (ttyConfig == null)
201 return;
202 ttyConfig.trim();
203 // set the console to be character-buffered instead of line-buffered
204 stty("-icanon min 1");
205 // disable character echoing
206 stty("-echo");
207 }
208
209 /** Restore original terminal configuration. */
210 protected synchronized void toOriginalTerminal() {
211 if (ttyConfig == null)
212 return;
213 try {
214 stty(ttyConfig);
215 } catch (Exception e) {
216 e.printStackTrace();
217 }
218 ttyConfig = null;
219 }
220
221 /**
222 * Execute the stty command with the specified arguments against the current
223 * active terminal.
224 */
225 protected String stty(String args) {
226 List<String> cmd = new ArrayList<>();
227 cmd.add("/bin/sh");
228 cmd.add("-c");
229 cmd.add(sttyExec + " " + args + " < /dev/tty");
230
231 logger.log(TRACE, () -> cmd.toString());
232
233 try {
234 ProcessBuilder pb = new ProcessBuilder(cmd);
235 Process p = pb.start();
236 String firstLine = new BufferedReader(new InputStreamReader(p.getInputStream())).readLine();
237 p.waitFor();
238 logger.log(TRACE, () -> firstLine);
239 return firstLine;
240 } catch (IOException | InterruptedException e) {
241 e.printStackTrace();
242 return null;
243 }
244 }
245
246 /*
247 * SCRIPT
248 */
249 private class ScriptThread extends Thread {
250 private SocketChannel channel;
251
252 public ScriptThread(SocketChannel channel) {
253 super("JShell script writer");
254 this.channel = channel;
255 }
256
257 @Override
258 public void run() {
259 try {
260 if (benchmark)
261 System.err.println(ManagementFactory.getRuntimeMXBean().getUptime());
262 StringBuilder sb = new StringBuilder();
263 if (!scriptArgs.isEmpty()) {
264 // additional arguments as $1, $2, etc.
265 for (String arg : scriptArgs)
266 sb.append('\"').append(arg).append('\"').append(";\n");
267 }
268 if (sb.length() > 0)
269 writeLine(sb);
270
271 try (BufferedReader reader = Files.newBufferedReader(script)) {
272 String line;
273 lines: while ((line = reader.readLine()) != null) {
274 if (line.startsWith("#"))
275 continue lines;
276 writeLine(line);
277 }
278 }
279
280 // exit
281 if (channel.isConnected())
282 writeLine("/exit");
283 } catch (IOException e) {
284 logger.log(ERROR, "Cannot execute " + script, e);
285 }
286 }
287
288 /** Not optimal, but performance is not critical here. */
289 private void writeLine(Object obj) throws IOException {
290 channel.write(ByteBuffer.wrap((obj + "\n").getBytes(UTF_8)));
291 }
292 }
293 }
294
295 /** Pipe streams to a channel. */
296 class SocketPipeSource {
297 private ReadableByteChannel inChannel;
298 private WritableByteChannel outChannel;
299
300 private Thread readThread;
301 private Thread forwardThread;
302
303 private int inBufferSize = 1;
304 private int outBufferSize = 1024;
305
306 private final String id;
307 private final boolean batch;
308
309 private boolean completed = false;
310
311 public SocketPipeSource(String id, boolean batch) {
312 this.id = id;
313 this.batch = batch;
314 }
315
316 public void process(SocketChannel channel) throws IOException {
317 if (batch) {
318 Integer socketRcvBuf = channel.getOption(StandardSocketOptions.SO_RCVBUF);
319 inBufferSize = socketRcvBuf;
320 outBufferSize = socketRcvBuf;
321 }
322
323 readThread = new Thread(() -> {
324
325 try {
326 ByteBuffer buffer = ByteBuffer.allocate(outBufferSize);
327 while (true) {
328 if (channel.read(buffer) < 0)
329 break;
330 buffer.flip();
331 outChannel.write(buffer);
332 buffer.rewind();
333 }
334 } catch (ClosedByInterruptException e) {
335 // silent
336 } catch (AsynchronousCloseException e) {
337 // silent
338 } catch (IOException e) {
339 e.printStackTrace();
340 }
341 markCompleted();
342 }, "JShell read " + id);
343 readThread.start();
344
345 // TODO make it smarter than a 1 byte buffer
346 // we should recognize control characters
347 // e.g ^C
348 // int c = System.in.read();
349 // if (c == 0x1B) {
350 // break;
351 // }
352
353 if (inChannel != null) {
354 forwardThread = new Thread(() -> {
355 try {
356 ByteBuffer buffer = ByteBuffer.allocate(inBufferSize);
357 while (channel.isConnected()) {
358 if (inChannel.read(buffer) < 0) {
359 System.err.println("in EOF");
360 channel.shutdownOutput();
361 break;
362 }
363 // int b = (int) buffer.get(0);
364 // if (b == 0x1B) {
365 // System.out.println("Ctrl+C");
366 // }
367
368 buffer.flip();
369 channel.write(buffer);
370 buffer.rewind();
371 }
372 } catch (IOException e) {
373 e.printStackTrace();
374 }
375 }, "JShell write " + id);
376 forwardThread.setDaemon(true);
377 forwardThread.start();
378 // end
379 // TODO make it more robust
380 // we want to be asynchronous when read only
381 // try {
382 // // TODO add timeout
383 // readThread.join();
384 // } catch (InterruptedException e) {
385 // e.printStackTrace();
386 // }
387
388 }
389 }
390
391 public synchronized boolean isCompleted() {
392 if (!completed)
393 try {
394 wait();
395 } catch (InterruptedException e) {
396 // silent
397 }
398 return completed;
399 }
400
401 protected synchronized void markCompleted() {
402 completed = true;
403 notifyAll();
404 }
405
406 public void shutdown() {
407 if (inChannel != null)
408 try {
409 inChannel.close();
410 } catch (IOException e) {
411 e.printStackTrace();
412 }
413 try {
414 outChannel.close();
415 } catch (IOException e) {
416 e.printStackTrace();
417 }
418 // if (inChannel != null)
419 // forwardThread.interrupt();
420 // readThread.interrupt();
421 }
422
423 public void setInputStream(InputStream in) {
424 inChannel = Channels.newChannel(in);
425 }
426
427 public void setOutputStream(OutputStream out) {
428 outChannel = Channels.newChannel(out);
429 }
430 }