// GtpClient.java package net.sf.gogui.gtp; import java.io.BufferedReader; import java.io.File; import java.io.InputStream; import java.io.InputStreamReader; import java.io.IOException; import java.io.OutputStream; import java.io.PrintWriter; import java.io.Reader; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; import net.sf.gogui.go.Move; import net.sf.gogui.util.StringUtil; import net.sf.gogui.util.ProcessUtil; /** Interface to a Go program that uses GTP over the standard I/O streams. <p> This class is final because it starts a thread in its constructor which might conflict with subclassing because the subclass constructor will be called after the thread is started. </p> <p> Callbacks can be registered to monitor the input, output and error stream and to handle timeout and invalid responses. </p> */ public final class GtpClient extends GtpClientBase { /** Exception thrown if executing a GTP engine failed. */ public static class ExecFailed extends GtpError { public String m_program; public ExecFailed(String program, String message) { super(message); m_program = program; } public ExecFailed(String program, IOException e) { this(program, e.getMessage()); } } /** Callback if a timeout occured. */ public interface TimeoutCallback { /** Ask for continuation. If this function returns true, Gtp.send will wait for another timeout period, if it returns false, the program will be killed. */ boolean askContinue(); } /** Callback if an invalid response occured. Can be used to display invalid responses (without a status character) immediately, because send will not abort on an invalid response but continue to wait for a valid response line. This is necessary for some Go programs with broken GTP implementation which write debug data to standard output (e.g. Wallyplus 0.1.2). */ public interface InvalidResponseCallback { void show(String line); } /** Callback interface for logging or displaying the GTP stream. Note that some of the callback functions are called from different threads. */ public interface IOCallback { void receivedInvalidResponse(String s); void receivedResponse(boolean error, String s); void receivedStdErr(String s); void sentCommand(String s); } /** Constructor. @param program Command line for program. Will be split into words with respect to " as in StringUtil.tokenize. If the command line contains the string "%SRAND", it will be replaced by a random seed. This is useful if the random seed can be set by a command line option to produce deterministic randomness (the command returned by getProgramCommand() will contain the actual random seed used). @param workingDirectory The working directory to run the program in or null for the current directory @param log Log input, output and error stream to standard error. @param callback Callback for external display of the streams. */ public GtpClient(String program, File workingDirectory, boolean log, IOCallback callback) throws GtpClient.ExecFailed { if (workingDirectory != null && ! workingDirectory.isDirectory()) throw new ExecFailed(program, "Invalid working directory \"" + workingDirectory + "\""); m_log = log; m_callback = callback; m_wasKilled = false; if (program.indexOf("%SRAND") >= 0) { // RAND_MAX in stdlib.h ist at least 32767 int randMax = 32767; int rand = (int)(Math.random() * (randMax + 1)); program = program.replaceAll("%SRAND", Integer.toString(rand)); } m_program = program; if (StringUtil.isEmpty(program)) throw new ExecFailed(program, "Command for invoking Go program must be" + " not empty."); Runtime runtime = Runtime.getRuntime(); try { // Create command array with StringUtil::splitArguments // because Runtime.exec(String) uses a default StringTokenizer // which does not respect ". String[] cmdArray = StringUtil.splitArguments(program); // Make file name absolute, if working directory is not current // directory. With Java 1.5, it seems that Runtime.exec succeeds // if the relative path is valid from the current, but not from // the given working directory, but the process is not usable // (reading from its input stream immediately returns // end-of-stream) if (cmdArray.length > 0) { File file = new File(cmdArray[0]); // Only replace if executable is a path to a file, not // an executable in the exec-path if (file.exists()) cmdArray[0] = file.getAbsolutePath(); } m_process = runtime.exec(cmdArray, null, workingDirectory); } catch (IOException e) { throw new ExecFailed(program, e); } init(m_process.getInputStream(), m_process.getOutputStream(), m_process.getErrorStream()); } /** Constructor for given input and output streams. */ public GtpClient(InputStream in, OutputStream out, boolean log, IOCallback callback) throws GtpError { m_log = log; m_callback = callback; m_program = "-"; m_process = null; init(in, out, null); } /** Close the output stream to the program. Some engines don't handle closing the command stream without an explicit quit command well, so the preferred way to terminate a connection is to send a quit command. Closing the output stream after a quit is not strictly necessary, but may improve compatibility with engines that read the input stream in a different thread */ public void close() { m_out.close(); } /** Kill the Go program. */ public void destroyProcess() { if (m_process != null) { m_wasKilled = true; m_process.destroy(); } } /** Did the engine ever send a valid response to a command? */ public boolean getAnyCommandsResponded() { return m_anyCommandsResponded; } /** Get response to last command sent. */ public String getResponse() { return m_response; } /** Get full response including status and ID and last command. */ public String getFullResponse() { return m_fullResponse; } /** Get the command line that was used for invoking the Go program. @return The command line that was given to the constructor. */ public String getProgramCommand() { return m_program; } /** Check if program is dead. */ public boolean isProgramDead() { return m_isProgramDead; } /** Send a command. @return The response text of the successful response not including the status character. @throws GtpError containing the response if the command fails. */ public String send(String command) throws GtpError { return send(command, -1, null); } public void queryName(long timeout, TimeoutCallback timeoutCallback) throws GtpError { m_name = send("name", timeout, timeoutCallback); } /** Send a command with timeout. @param command The command to send @param timeout Timeout in milliseconds or -1, if no timeout @param timeoutCallback Timeout callback or null if no timeout. @return The response text of the successful response not including the status character. @throws GtpError containing the response if the command fails. @see TimeoutCallback */ public String send(String command, long timeout, TimeoutCallback timeoutCallback) throws GtpError { assert ! command.trim().equals(""); assert ! command.trim().startsWith("#"); m_timeoutCallback = timeoutCallback; m_fullResponse = ""; m_response = ""; ++m_commandNumber; if (m_autoNumber) command = Integer.toString(m_commandNumber) + " " + command; if (m_log) logOut(command); m_out.println(command); m_out.flush(); try { if (m_out.checkError()) { throwProgramDied(); } if (m_callback != null) m_callback.sentCommand(command); readResponse(timeout); return m_response; } catch (GtpError e) { e.setCommand(command); throw e; } } public void sendPlay(Move move, long timeout, TimeoutCallback timeoutCallback) throws GtpError { send(getCommandPlay(move), timeout, timeoutCallback); } /** Send comment. @param comment comment line (must start with '#'). */ public void sendComment(String comment) { assert comment.trim().startsWith("#"); if (m_log) logOut(comment); if (m_callback != null) m_callback.sentCommand(comment); m_out.println(comment); m_out.flush(); } /** Enable auto-numbering commands. Every command will be prepended by an integer as defined in the GTP standard, the integer is incremented after each command. */ public void setAutoNumber(boolean enable) { m_autoNumber = enable; } /** Set the callback for invalid responses. @see InvalidResponseCallback */ public void setInvalidResponseCallback(InvalidResponseCallback callback) { m_invalidResponseCallback = callback; } public void setIOCallback(GtpClient.IOCallback callback) { m_callback = callback; } /** Set a prefix for logging to standard error. Only used if logging was enabled in the constructor. */ public void setLogPrefix(String prefix) { synchronized (this) { m_logPrefix = prefix; } } /** Wait until the process of the program exits. */ public void waitForExit() { if (m_process == null) return; try { m_process.waitFor(); m_errorThread.join(); m_inputThread.join(); } catch (InterruptedException e) { printInterrupted(); } } /** More sophisticated version of waitFor with timeout. */ public void waitForExit(int timeout, TimeoutCallback timeoutCallback) { if (m_process == null) return; while (true) { if (ProcessUtil.waitForExit(m_process, timeout)) break; if (! timeoutCallback.askContinue()) { m_process.destroy(); return; } } try { m_errorThread.join(timeout); m_inputThread.join(timeout); } catch (InterruptedException e) { printInterrupted(); } } /** Was program forcefully terminated by calling destroyProcess() */ public boolean wasKilled() { return m_wasKilled; } private static final class Message { public Message(String text) { m_text = text; } public String m_text; } private class InputThread extends Thread { InputThread(InputStream in, BlockingQueue<Message> queue) { m_in = new BufferedReader(new InputStreamReader(in)); m_queue = queue; } public void run() { try { mainLoop(); } catch (Throwable t) { StringUtil.printException(t); } } private final BufferedReader m_in; private final BlockingQueue<Message> m_queue; private final StringBuilder m_buffer = new StringBuilder(1024); private void appendBuffer(String line) { m_buffer.append(line); m_buffer.append('\n'); } private boolean isResponseStart(String line) { if (line.length() < 1) return false; char c = line.charAt(0); return (c == '=' || c == '?'); } private void mainLoop() throws InterruptedException { while (true) { String line = readLine(); if (line == null) { putMessage(null); return; } appendBuffer(line); if (! isResponseStart(line)) { if (! line.trim().equals("")) { if (m_callback != null) m_callback.receivedInvalidResponse(line); if (m_invalidResponseCallback != null) m_invalidResponseCallback.show(line); } m_buffer.setLength(0); continue; } while (true) { line = readLine(); appendBuffer(line); if (line == null) { putMessage(null); return; } if (line.equals("")) { putMessage(); break; } } } } private void putMessage() { // Calling Thread.yield increases the probability that the IO // callbacks for stderr and stdout are called in the right order // for the typical use case of a program writing to stderr // before writing the response. The yield costs some performance // however and could have a negative effect, if the program // writes to stderr immediately after the response (e.g. logging // output during pondering). Thread.yield(); putMessage(m_buffer.toString()); m_buffer.setLength(0); } private void putMessage(String text) { try { m_queue.put(new Message(text)); } catch (InterruptedException e) { printInterrupted(); } } private String readLine() { try { String line = m_in.readLine(); if (m_log && line != null) logIn(line); return line; } catch (IOException e) { return null; } } } private class ErrorThread extends Thread { public ErrorThread(InputStream in, BlockingQueue<Message> queue) { m_in = new InputStreamReader(in); m_queue = queue; } public void run() { try { char[] buffer = new char[4096]; while (true) { int n; try { n = m_in.read(buffer); } catch (IOException e) { return; } if (n <= 0) return; String text = new String(buffer, 0, n); if (m_callback != null) m_callback.receivedStdErr(text); if (m_log) logError(text); } } catch (Throwable t) { StringUtil.printException(t); } } private final Reader m_in; } private InvalidResponseCallback m_invalidResponseCallback; private boolean m_autoNumber; private boolean m_anyCommandsResponded; private boolean m_isProgramDead; private boolean m_wasKilled; private final boolean m_log; private int m_commandNumber; private IOCallback m_callback; private PrintWriter m_out; private Process m_process; private String m_fullResponse; private String m_response; private String m_logPrefix; private final String m_program; private BlockingQueue<Message> m_queue; private TimeoutCallback m_timeoutCallback; private InputThread m_inputThread; private ErrorThread m_errorThread; private void init(InputStream in, OutputStream out, InputStream err) { m_out = new PrintWriter(out); m_isProgramDead = false; m_queue = new ArrayBlockingQueue<Message>(10); m_inputThread = new InputThread(in, m_queue); if (err != null) { m_errorThread = new ErrorThread(err, m_queue); m_errorThread.start(); } m_inputThread.start(); } private synchronized void logError(String text) { System.err.print(text); } private synchronized void logIn(String msg) { if (m_logPrefix != null) System.err.print(m_logPrefix); System.err.print("<< "); System.err.println(msg); } private synchronized void logOut(String msg) { if (m_logPrefix != null) System.err.print(m_logPrefix); System.err.print(">> "); System.err.println(msg); } /** Print information about occurence of InterruptedException. An InterruptedException should never happen, because we don't call Thread.interrupt */ private void printInterrupted() { System.err.println("GtpClient: InterruptedException"); Thread.dumpStack(); } private String readResponse(long timeout) throws GtpError { while (true) { Message message = waitForMessage(timeout); String response = message.m_text; if (response == null) { m_isProgramDead = true; throwProgramDied(); } m_anyCommandsResponded = true; boolean error = (response.charAt(0) != '='); m_fullResponse = response; if (m_callback != null) m_callback.receivedResponse(error, m_fullResponse); assert response.length() >= 3; int index = response.indexOf(' '); int length = response.length(); if (index < 0) m_response = response.substring(1, length - 2); else m_response = response.substring(index + 1, length - 2); if (error) throw new GtpError(m_response); return m_response; } } private void throwProgramDied() throws GtpError { m_isProgramDead = true; String name = m_name; if (name == null) name = "The Go program"; if (m_wasKilled) throw new GtpError(name + " terminated."); else throw new GtpError(name + " terminated unexpectedly."); } private Message waitForMessage(long timeout) throws GtpError { Message message = null; if (timeout < 0) { try { message = m_queue.take(); } catch (InterruptedException e) { printInterrupted(); destroyProcess(); throwProgramDied(); } } else { message = null; while (message == null) { try { message = m_queue.poll(timeout, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { printInterrupted(); } if (message == null) { assert m_timeoutCallback != null; if (! m_timeoutCallback.askContinue()) { destroyProcess(); throwProgramDied(); } } } } return message; } }