1 /* 2 * Copyright (C) 2007 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.server; 18 19 import android.net.LocalSocket; 20 import android.net.LocalSocketAddress; 21 import android.os.Build; 22 import android.os.Handler; 23 import android.os.Looper; 24 import android.os.Message; 25 import android.os.PowerManager; 26 import android.os.SystemClock; 27 import android.util.LocalLog; 28 import android.util.Slog; 29 30 import com.android.internal.annotations.VisibleForTesting; 31 import com.android.internal.util.Preconditions; 32 import com.google.android.collect.Lists; 33 34 import java.io.FileDescriptor; 35 import java.io.IOException; 36 import java.io.InputStream; 37 import java.io.OutputStream; 38 import java.io.PrintWriter; 39 import java.nio.charset.StandardCharsets; 40 import java.util.ArrayList; 41 import java.util.concurrent.atomic.AtomicInteger; 42 import java.util.concurrent.ArrayBlockingQueue; 43 import java.util.concurrent.BlockingQueue; 44 import java.util.concurrent.CountDownLatch; 45 import java.util.concurrent.TimeUnit; 46 import java.util.LinkedList; 47 48 /** 49 * Generic connector class for interfacing with a native daemon which uses the 50 * {@code libsysutils} FrameworkListener protocol. 51 */ 52 final class NativeDaemonConnector implements Runnable, Handler.Callback, Watchdog.Monitor { 53 private final static boolean VDBG = false; 54 55 private final String TAG; 56 57 private String mSocket; 58 private OutputStream mOutputStream; 59 private LocalLog mLocalLog; 60 61 private volatile boolean mDebug = false; 62 private volatile Object mWarnIfHeld; 63 64 private final ResponseQueue mResponseQueue; 65 66 private final PowerManager.WakeLock mWakeLock; 67 68 private final Looper mLooper; 69 70 private INativeDaemonConnectorCallbacks mCallbacks; 71 private Handler mCallbackHandler; 72 73 private AtomicInteger mSequenceNumber; 74 75 private static final long DEFAULT_TIMEOUT = 1 * 60 * 1000; /* 1 minute */ 76 private static final long WARN_EXECUTE_DELAY_MS = 500; /* .5 sec */ 77 78 /** Lock held whenever communicating with native daemon. */ 79 private final Object mDaemonLock = new Object(); 80 81 private final int BUFFER_SIZE = 4096; 82 83 NativeDaemonConnector(INativeDaemonConnectorCallbacks callbacks, String socket, 84 int responseQueueSize, String logTag, int maxLogSize, PowerManager.WakeLock wl) { 85 this(callbacks, socket, responseQueueSize, logTag, maxLogSize, wl, 86 FgThread.get().getLooper()); 87 } 88 89 NativeDaemonConnector(INativeDaemonConnectorCallbacks callbacks, String socket, 90 int responseQueueSize, String logTag, int maxLogSize, PowerManager.WakeLock wl, 91 Looper looper) { 92 mCallbacks = callbacks; 93 mSocket = socket; 94 mResponseQueue = new ResponseQueue(responseQueueSize); 95 mWakeLock = wl; 96 if (mWakeLock != null) { 97 mWakeLock.setReferenceCounted(true); 98 } 99 mLooper = looper; 100 mSequenceNumber = new AtomicInteger(0); 101 TAG = logTag != null ? logTag : "NativeDaemonConnector"; 102 mLocalLog = new LocalLog(maxLogSize); 103 } 104 105 /** 106 * Enable Set debugging mode, which causes messages to also be written to both 107 * {@link Slog} in addition to internal log. 108 */ 109 public void setDebug(boolean debug) { 110 mDebug = debug; 111 } 112 113 /** 114 * Like SystemClock.uptimeMillis, except truncated to an int so it will fit in a message arg. 115 * Inaccurate across 49.7 days of uptime, but only used for debugging. 116 */ 117 private int uptimeMillisInt() { 118 return (int) SystemClock.uptimeMillis() & Integer.MAX_VALUE; 119 } 120 121 /** 122 * Yell loudly if someone tries making future {@link #execute(Command)} 123 * calls while holding a lock on the given object. 124 */ 125 public void setWarnIfHeld(Object warnIfHeld) { 126 Preconditions.checkState(mWarnIfHeld == null); 127 mWarnIfHeld = Preconditions.checkNotNull(warnIfHeld); 128 } 129 130 @Override 131 public void run() { 132 mCallbackHandler = new Handler(mLooper, this); 133 134 while (true) { 135 try { 136 listenToSocket(); 137 } catch (Exception e) { 138 loge("Error in NativeDaemonConnector: " + e); 139 SystemClock.sleep(5000); 140 } 141 } 142 } 143 144 @Override 145 public boolean handleMessage(Message msg) { 146 final String event = (String) msg.obj; 147 final int start = uptimeMillisInt(); 148 final int sent = msg.arg1; 149 try { 150 if (!mCallbacks.onEvent(msg.what, event, NativeDaemonEvent.unescapeArgs(event))) { 151 log(String.format("Unhandled event '%s'", event)); 152 } 153 } catch (Exception e) { 154 loge("Error handling '" + event + "': " + e); 155 } finally { 156 if (mCallbacks.onCheckHoldWakeLock(msg.what) && mWakeLock != null) { 157 mWakeLock.release(); 158 } 159 final int end = uptimeMillisInt(); 160 if (start > sent && start - sent > WARN_EXECUTE_DELAY_MS) { 161 loge(String.format("NDC event {%s} processed too late: %dms", event, start - sent)); 162 } 163 if (end > start && end - start > WARN_EXECUTE_DELAY_MS) { 164 loge(String.format("NDC event {%s} took too long: %dms", event, end - start)); 165 } 166 } 167 return true; 168 } 169 170 private LocalSocketAddress determineSocketAddress() { 171 // If we're testing, set up a socket in a namespace that's accessible to test code. 172 // In order to ensure that unprivileged apps aren't able to impersonate native daemons on 173 // production devices, even if said native daemons ill-advisedly pick a socket name that 174 // starts with __test__, only allow this on debug builds. 175 if (mSocket.startsWith("__test__") && Build.IS_DEBUGGABLE) { 176 return new LocalSocketAddress(mSocket); 177 } else { 178 return new LocalSocketAddress(mSocket, LocalSocketAddress.Namespace.RESERVED); 179 } 180 } 181 182 private void listenToSocket() throws IOException { 183 LocalSocket socket = null; 184 185 try { 186 socket = new LocalSocket(); 187 LocalSocketAddress address = determineSocketAddress(); 188 189 socket.connect(address); 190 191 InputStream inputStream = socket.getInputStream(); 192 synchronized (mDaemonLock) { 193 mOutputStream = socket.getOutputStream(); 194 } 195 196 mCallbacks.onDaemonConnected(); 197 198 FileDescriptor[] fdList = null; 199 byte[] buffer = new byte[BUFFER_SIZE]; 200 int start = 0; 201 202 while (true) { 203 int count = inputStream.read(buffer, start, BUFFER_SIZE - start); 204 if (count < 0) { 205 loge("got " + count + " reading with start = " + start); 206 break; 207 } 208 fdList = socket.getAncillaryFileDescriptors(); 209 210 // Add our starting point to the count and reset the start. 211 count += start; 212 start = 0; 213 214 for (int i = 0; i < count; i++) { 215 if (buffer[i] == 0) { 216 // Note - do not log this raw message since it may contain 217 // sensitive data 218 final String rawEvent = new String( 219 buffer, start, i - start, StandardCharsets.UTF_8); 220 221 boolean releaseWl = false; 222 try { 223 final NativeDaemonEvent event = 224 NativeDaemonEvent.parseRawEvent(rawEvent, fdList); 225 226 log("RCV <- {" + event + "}"); 227 228 if (event.isClassUnsolicited()) { 229 // TODO: migrate to sending NativeDaemonEvent instances 230 if (mCallbacks.onCheckHoldWakeLock(event.getCode()) 231 && mWakeLock != null) { 232 mWakeLock.acquire(); 233 releaseWl = true; 234 } 235 Message msg = mCallbackHandler.obtainMessage( 236 event.getCode(), uptimeMillisInt(), 0, event.getRawEvent()); 237 if (mCallbackHandler.sendMessage(msg)) { 238 releaseWl = false; 239 } 240 } else { 241 mResponseQueue.add(event.getCmdNumber(), event); 242 } 243 } catch (IllegalArgumentException e) { 244 log("Problem parsing message " + e); 245 } finally { 246 if (releaseWl) { 247 mWakeLock.release(); 248 } 249 } 250 251 start = i + 1; 252 } 253 } 254 255 if (start == 0) { 256 log("RCV incomplete"); 257 } 258 259 // We should end at the amount we read. If not, compact then 260 // buffer and read again. 261 if (start != count) { 262 final int remaining = BUFFER_SIZE - start; 263 System.arraycopy(buffer, start, buffer, 0, remaining); 264 start = remaining; 265 } else { 266 start = 0; 267 } 268 } 269 } catch (IOException ex) { 270 loge("Communications error: " + ex); 271 throw ex; 272 } finally { 273 synchronized (mDaemonLock) { 274 if (mOutputStream != null) { 275 try { 276 loge("closing stream for " + mSocket); 277 mOutputStream.close(); 278 } catch (IOException e) { 279 loge("Failed closing output stream: " + e); 280 } 281 mOutputStream = null; 282 } 283 } 284 285 try { 286 if (socket != null) { 287 socket.close(); 288 } 289 } catch (IOException ex) { 290 loge("Failed closing socket: " + ex); 291 } 292 } 293 } 294 295 /** 296 * Wrapper around argument that indicates it's sensitive and shouldn't be 297 * logged. 298 */ 299 public static class SensitiveArg { 300 private final Object mArg; 301 302 public SensitiveArg(Object arg) { 303 mArg = arg; 304 } 305 306 @Override 307 public String toString() { 308 return String.valueOf(mArg); 309 } 310 } 311 312 /** 313 * Make command for daemon, escaping arguments as needed. 314 */ 315 @VisibleForTesting 316 static void makeCommand(StringBuilder rawBuilder, StringBuilder logBuilder, int sequenceNumber, 317 String cmd, Object... args) { 318 if (cmd.indexOf('\0') >= 0) { 319 throw new IllegalArgumentException("Unexpected command: " + cmd); 320 } 321 if (cmd.indexOf(' ') >= 0) { 322 throw new IllegalArgumentException("Arguments must be separate from command"); 323 } 324 325 rawBuilder.append(sequenceNumber).append(' ').append(cmd); 326 logBuilder.append(sequenceNumber).append(' ').append(cmd); 327 for (Object arg : args) { 328 final String argString = String.valueOf(arg); 329 if (argString.indexOf('\0') >= 0) { 330 throw new IllegalArgumentException("Unexpected argument: " + arg); 331 } 332 333 rawBuilder.append(' '); 334 logBuilder.append(' '); 335 336 appendEscaped(rawBuilder, argString); 337 if (arg instanceof SensitiveArg) { 338 logBuilder.append("[scrubbed]"); 339 } else { 340 appendEscaped(logBuilder, argString); 341 } 342 } 343 344 rawBuilder.append('\0'); 345 } 346 347 /** 348 * Method that waits until all asychronous notifications sent by the native daemon have 349 * been processed. This method must not be called on the notification thread or an 350 * exception will be thrown. 351 */ 352 public void waitForCallbacks() { 353 if (Thread.currentThread() == mLooper.getThread()) { 354 throw new IllegalStateException("Must not call this method on callback thread"); 355 } 356 357 final CountDownLatch latch = new CountDownLatch(1); 358 mCallbackHandler.post(new Runnable() { 359 @Override 360 public void run() { 361 latch.countDown(); 362 } 363 }); 364 try { 365 latch.await(); 366 } catch (InterruptedException e) { 367 Slog.wtf(TAG, "Interrupted while waiting for unsolicited response handling", e); 368 } 369 } 370 371 /** 372 * Issue the given command to the native daemon and return a single expected 373 * response. 374 * 375 * @throws NativeDaemonConnectorException when problem communicating with 376 * native daemon, or if the response matches 377 * {@link NativeDaemonEvent#isClassClientError()} or 378 * {@link NativeDaemonEvent#isClassServerError()}. 379 */ 380 public NativeDaemonEvent execute(Command cmd) throws NativeDaemonConnectorException { 381 return execute(cmd.mCmd, cmd.mArguments.toArray()); 382 } 383 384 /** 385 * Issue the given command to the native daemon and return a single expected 386 * response. Any arguments must be separated from base command so they can 387 * be properly escaped. 388 * 389 * @throws NativeDaemonConnectorException when problem communicating with 390 * native daemon, or if the response matches 391 * {@link NativeDaemonEvent#isClassClientError()} or 392 * {@link NativeDaemonEvent#isClassServerError()}. 393 */ 394 public NativeDaemonEvent execute(String cmd, Object... args) 395 throws NativeDaemonConnectorException { 396 return execute(DEFAULT_TIMEOUT, cmd, args); 397 } 398 399 public NativeDaemonEvent execute(long timeoutMs, String cmd, Object... args) 400 throws NativeDaemonConnectorException { 401 final NativeDaemonEvent[] events = executeForList(timeoutMs, cmd, args); 402 if (events.length != 1) { 403 throw new NativeDaemonConnectorException( 404 "Expected exactly one response, but received " + events.length); 405 } 406 return events[0]; 407 } 408 409 /** 410 * Issue the given command to the native daemon and return any 411 * {@link NativeDaemonEvent#isClassContinue()} responses, including the 412 * final terminal response. 413 * 414 * @throws NativeDaemonConnectorException when problem communicating with 415 * native daemon, or if the response matches 416 * {@link NativeDaemonEvent#isClassClientError()} or 417 * {@link NativeDaemonEvent#isClassServerError()}. 418 */ 419 public NativeDaemonEvent[] executeForList(Command cmd) throws NativeDaemonConnectorException { 420 return executeForList(cmd.mCmd, cmd.mArguments.toArray()); 421 } 422 423 /** 424 * Issue the given command to the native daemon and return any 425 * {@link NativeDaemonEvent#isClassContinue()} responses, including the 426 * final terminal response. Any arguments must be separated from base 427 * command so they can be properly escaped. 428 * 429 * @throws NativeDaemonConnectorException when problem communicating with 430 * native daemon, or if the response matches 431 * {@link NativeDaemonEvent#isClassClientError()} or 432 * {@link NativeDaemonEvent#isClassServerError()}. 433 */ 434 public NativeDaemonEvent[] executeForList(String cmd, Object... args) 435 throws NativeDaemonConnectorException { 436 return executeForList(DEFAULT_TIMEOUT, cmd, args); 437 } 438 439 /** 440 * Issue the given command to the native daemon and return any {@linke 441 * NativeDaemonEvent@isClassContinue()} responses, including the final 442 * terminal response. Note that the timeout does not count time in deep 443 * sleep. Any arguments must be separated from base command so they can be 444 * properly escaped. 445 * 446 * @throws NativeDaemonConnectorException when problem communicating with 447 * native daemon, or if the response matches 448 * {@link NativeDaemonEvent#isClassClientError()} or 449 * {@link NativeDaemonEvent#isClassServerError()}. 450 */ 451 public NativeDaemonEvent[] executeForList(long timeoutMs, String cmd, Object... args) 452 throws NativeDaemonConnectorException { 453 if (mWarnIfHeld != null && Thread.holdsLock(mWarnIfHeld)) { 454 Slog.wtf(TAG, "Calling thread " + Thread.currentThread().getName() + " is holding 0x" 455 + Integer.toHexString(System.identityHashCode(mWarnIfHeld)), new Throwable()); 456 } 457 458 final long startTime = SystemClock.elapsedRealtime(); 459 460 final ArrayList<NativeDaemonEvent> events = Lists.newArrayList(); 461 462 final StringBuilder rawBuilder = new StringBuilder(); 463 final StringBuilder logBuilder = new StringBuilder(); 464 final int sequenceNumber = mSequenceNumber.incrementAndGet(); 465 466 makeCommand(rawBuilder, logBuilder, sequenceNumber, cmd, args); 467 468 final String rawCmd = rawBuilder.toString(); 469 final String logCmd = logBuilder.toString(); 470 471 log("SND -> {" + logCmd + "}"); 472 473 synchronized (mDaemonLock) { 474 if (mOutputStream == null) { 475 throw new NativeDaemonConnectorException("missing output stream"); 476 } else { 477 try { 478 mOutputStream.write(rawCmd.getBytes(StandardCharsets.UTF_8)); 479 } catch (IOException e) { 480 throw new NativeDaemonConnectorException("problem sending command", e); 481 } 482 } 483 } 484 485 NativeDaemonEvent event = null; 486 do { 487 event = mResponseQueue.remove(sequenceNumber, timeoutMs, logCmd); 488 if (event == null) { 489 loge("timed-out waiting for response to " + logCmd); 490 throw new NativeDaemonTimeoutException(logCmd, event); 491 } 492 if (VDBG) log("RMV <- {" + event + "}"); 493 events.add(event); 494 } while (event.isClassContinue()); 495 496 final long endTime = SystemClock.elapsedRealtime(); 497 if (endTime - startTime > WARN_EXECUTE_DELAY_MS) { 498 loge("NDC Command {" + logCmd + "} took too long (" + (endTime - startTime) + "ms)"); 499 } 500 501 if (event.isClassClientError()) { 502 throw new NativeDaemonArgumentException(logCmd, event); 503 } 504 if (event.isClassServerError()) { 505 throw new NativeDaemonFailureException(logCmd, event); 506 } 507 508 return events.toArray(new NativeDaemonEvent[events.size()]); 509 } 510 511 /** 512 * Append the given argument to {@link StringBuilder}, escaping as needed, 513 * and surrounding with quotes when it contains spaces. 514 */ 515 @VisibleForTesting 516 static void appendEscaped(StringBuilder builder, String arg) { 517 final boolean hasSpaces = arg.indexOf(' ') >= 0; 518 if (hasSpaces) { 519 builder.append('"'); 520 } 521 522 final int length = arg.length(); 523 for (int i = 0; i < length; i++) { 524 final char c = arg.charAt(i); 525 526 if (c == '"') { 527 builder.append("\\\""); 528 } else if (c == '\\') { 529 builder.append("\\\\"); 530 } else { 531 builder.append(c); 532 } 533 } 534 535 if (hasSpaces) { 536 builder.append('"'); 537 } 538 } 539 540 private static class NativeDaemonArgumentException extends NativeDaemonConnectorException { 541 public NativeDaemonArgumentException(String command, NativeDaemonEvent event) { 542 super(command, event); 543 } 544 545 @Override 546 public IllegalArgumentException rethrowAsParcelableException() { 547 throw new IllegalArgumentException(getMessage(), this); 548 } 549 } 550 551 private static class NativeDaemonFailureException extends NativeDaemonConnectorException { 552 public NativeDaemonFailureException(String command, NativeDaemonEvent event) { 553 super(command, event); 554 } 555 } 556 557 /** 558 * Command builder that handles argument list building. Any arguments must 559 * be separated from base command so they can be properly escaped. 560 */ 561 public static class Command { 562 private String mCmd; 563 private ArrayList<Object> mArguments = Lists.newArrayList(); 564 565 public Command(String cmd, Object... args) { 566 mCmd = cmd; 567 for (Object arg : args) { 568 appendArg(arg); 569 } 570 } 571 572 public Command appendArg(Object arg) { 573 mArguments.add(arg); 574 return this; 575 } 576 } 577 578 /** {@inheritDoc} */ 579 public void monitor() { 580 synchronized (mDaemonLock) { } 581 } 582 583 public void dump(FileDescriptor fd, PrintWriter pw, String[] args) { 584 mLocalLog.dump(fd, pw, args); 585 pw.println(); 586 mResponseQueue.dump(fd, pw, args); 587 } 588 589 private void log(String logstring) { 590 if (mDebug) Slog.d(TAG, logstring); 591 mLocalLog.log(logstring); 592 } 593 594 private void loge(String logstring) { 595 Slog.e(TAG, logstring); 596 mLocalLog.log(logstring); 597 } 598 599 private static class ResponseQueue { 600 601 private static class PendingCmd { 602 public final int cmdNum; 603 public final String logCmd; 604 605 public BlockingQueue<NativeDaemonEvent> responses = 606 new ArrayBlockingQueue<NativeDaemonEvent>(10); 607 608 // The availableResponseCount member is used to track when we can remove this 609 // instance from the ResponseQueue. 610 // This is used under the protection of a sync of the mPendingCmds object. 611 // A positive value means we've had more writers retreive this object while 612 // a negative value means we've had more readers. When we've had an equal number 613 // (it goes to zero) we can remove this object from the mPendingCmds list. 614 // Note that we may have more responses for this command (and more readers 615 // coming), but that would result in a new PendingCmd instance being created 616 // and added with the same cmdNum. 617 // Also note that when this goes to zero it just means a parity of readers and 618 // writers have retrieved this object - not that they are done using it. The 619 // responses queue may well have more responses yet to be read or may get more 620 // responses added to it. But all those readers/writers have retreived and 621 // hold references to this instance already so it can be removed from 622 // mPendingCmds queue. 623 public int availableResponseCount; 624 625 public PendingCmd(int cmdNum, String logCmd) { 626 this.cmdNum = cmdNum; 627 this.logCmd = logCmd; 628 } 629 } 630 631 private final LinkedList<PendingCmd> mPendingCmds; 632 private int mMaxCount; 633 634 ResponseQueue(int maxCount) { 635 mPendingCmds = new LinkedList<PendingCmd>(); 636 mMaxCount = maxCount; 637 } 638 639 public void add(int cmdNum, NativeDaemonEvent response) { 640 PendingCmd found = null; 641 synchronized (mPendingCmds) { 642 for (PendingCmd pendingCmd : mPendingCmds) { 643 if (pendingCmd.cmdNum == cmdNum) { 644 found = pendingCmd; 645 break; 646 } 647 } 648 if (found == null) { 649 // didn't find it - make sure our queue isn't too big before adding 650 while (mPendingCmds.size() >= mMaxCount) { 651 Slog.e("NativeDaemonConnector.ResponseQueue", 652 "more buffered than allowed: " + mPendingCmds.size() + 653 " >= " + mMaxCount); 654 // let any waiter timeout waiting for this 655 PendingCmd pendingCmd = mPendingCmds.remove(); 656 Slog.e("NativeDaemonConnector.ResponseQueue", 657 "Removing request: " + pendingCmd.logCmd + " (" + 658 pendingCmd.cmdNum + ")"); 659 } 660 found = new PendingCmd(cmdNum, null); 661 mPendingCmds.add(found); 662 } 663 found.availableResponseCount++; 664 // if a matching remove call has already retrieved this we can remove this 665 // instance from our list 666 if (found.availableResponseCount == 0) mPendingCmds.remove(found); 667 } 668 try { 669 found.responses.put(response); 670 } catch (InterruptedException e) { } 671 } 672 673 // note that the timeout does not count time in deep sleep. If you don't want 674 // the device to sleep, hold a wakelock 675 public NativeDaemonEvent remove(int cmdNum, long timeoutMs, String logCmd) { 676 PendingCmd found = null; 677 synchronized (mPendingCmds) { 678 for (PendingCmd pendingCmd : mPendingCmds) { 679 if (pendingCmd.cmdNum == cmdNum) { 680 found = pendingCmd; 681 break; 682 } 683 } 684 if (found == null) { 685 found = new PendingCmd(cmdNum, logCmd); 686 mPendingCmds.add(found); 687 } 688 found.availableResponseCount--; 689 // if a matching add call has already retrieved this we can remove this 690 // instance from our list 691 if (found.availableResponseCount == 0) mPendingCmds.remove(found); 692 } 693 NativeDaemonEvent result = null; 694 try { 695 result = found.responses.poll(timeoutMs, TimeUnit.MILLISECONDS); 696 } catch (InterruptedException e) {} 697 if (result == null) { 698 Slog.e("NativeDaemonConnector.ResponseQueue", "Timeout waiting for response"); 699 } 700 return result; 701 } 702 703 public void dump(FileDescriptor fd, PrintWriter pw, String[] args) { 704 pw.println("Pending requests:"); 705 synchronized (mPendingCmds) { 706 for (PendingCmd pendingCmd : mPendingCmds) { 707 pw.println(" Cmd " + pendingCmd.cmdNum + " - " + pendingCmd.logCmd); 708 } 709 } 710 } 711 } 712 } 713