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
17package tests.support;
18
19import java.io.*;
20import java.lang.Thread;
21import java.net.*;
22import java.text.SimpleDateFormat;
23import java.util.*;
24import java.util.concurrent.ConcurrentHashMap;
25import java.util.logging.Logger;
26
27/**
28 * TestWebServer is a simulated controllable test server that
29 * can respond to requests from HTTP clients.
30 *
31 * The server can be controlled to change how it reacts to any
32 * requests, and can be told to simulate various events (such as
33 * network failure) that would happen in a real environment.
34 */
35public class Support_TestWebServer implements Support_HttpConstants {
36
37    /* static class data/methods */
38
39    /* The ANDROID_LOG_TAG */
40    private final static String LOGTAG = "httpsv";
41
42    /** maps the recently requested URLs to the full request snapshot */
43    private final Map<String, Request> pathToRequest
44            = new ConcurrentHashMap<String, Request>();
45
46    /* timeout on client connections */
47    int timeout = 0;
48
49    /* Default socket timeout value */
50    final static int DEFAULT_TIMEOUT = 5000;
51
52    /* Version string (configurable) */
53    protected String HTTP_VERSION_STRING = "HTTP/1.1";
54
55    /* Indicator for whether this server is configured as a HTTP/1.1
56     * or HTTP/1.0 server
57     */
58    private boolean http11 = true;
59
60    /* The thread handling new requests from clients */
61    private AcceptThread acceptT;
62
63    /* timeout on client connections */
64    int mTimeout;
65
66    /* Server port */
67    int mPort;
68
69    /* Switch on/off logging */
70    boolean mLog = false;
71
72    /* If set, this will keep connections alive after a request has been
73     * processed.
74     */
75    boolean keepAlive = true;
76
77    /* If set, this will cause response data to be sent in 'chunked' format */
78    boolean chunked = false;
79    int maxChunkSize = 1024;
80
81    /* If set, this will indicate a new redirection host */
82    String redirectHost = null;
83
84    /* If set, this indicates the reason for redirection */
85    int redirectCode = -1;
86
87    /* Set the number of connections the server will accept before shutdown */
88    int acceptLimit = 100;
89
90    /* Count of number of accepted connections */
91    int acceptedConnections = 0;
92
93    public Support_TestWebServer() {
94    }
95
96    /**
97     * @param servePath the path to the dynamic web test data
98     * @param contentType the type of the dynamic web test data
99     */
100    public int initServer(String servePath, String contentType) throws Exception {
101        Support_TestWebData.initDynamicTestWebData(servePath, contentType);
102        return initServer();
103    }
104
105    public int initServer() throws Exception {
106        mTimeout = DEFAULT_TIMEOUT;
107        mLog = false;
108        keepAlive = true;
109        if (acceptT == null) {
110            acceptT = new AcceptThread();
111            mPort = acceptT.init();
112            acceptT.start();
113        }
114        return mPort;
115    }
116
117    /**
118     * Print to the log file (if logging enabled)
119     * @param s String to send to the log
120     */
121    protected void log(String s) {
122        if (mLog) {
123            Logger.global.fine(s);
124        }
125    }
126
127    /**
128     * Set the server to be an HTTP/1.0 or HTTP/1.1 server.
129     * This should be called prior to any requests being sent
130     * to the server.
131     * @param set True for the server to be HTTP/1.1, false for HTTP/1.0
132     */
133    public void setHttpVersion11(boolean set) {
134        http11 = set;
135        if (set) {
136            HTTP_VERSION_STRING = "HTTP/1.1";
137        } else {
138            HTTP_VERSION_STRING = "HTTP/1.0";
139        }
140    }
141
142    /**
143     * Call this to determine whether server connection should remain open
144     * @param value Set true to keep connections open after a request
145     *              completes
146     */
147    public void setKeepAlive(boolean value) {
148        keepAlive = value;
149    }
150
151    /**
152     * Call this to indicate whether chunked data should be used
153     * @param value Set true to make server respond with chunk encoded
154     *              content data.
155     */
156    public void setChunked(boolean value) {
157        chunked = value;
158    }
159
160    /**
161     * Sets the maximum byte count of any chunk if the server is using
162     * the "chunked" transfer encoding.
163     */
164    public void setMaxChunkSize(int maxChunkSize) {
165        this.maxChunkSize = maxChunkSize;
166    }
167
168    /**
169     * Call this to specify the maximum number of sockets to accept
170     * @param limit The number of sockets to accept
171     */
172    public void setAcceptLimit(int limit) {
173        acceptLimit = limit;
174    }
175
176    /**
177     * Call this to indicate redirection port requirement.
178     * When this value is set, the server will respond to a request with
179     * a redirect code with the Location response header set to the value
180     * specified.
181     * @param redirect The location to be redirected to
182     * @param code The code to send when redirecting
183     */
184    public void setRedirect(String redirect, int code) {
185        redirectHost = redirect;
186        redirectCode = code;
187        log("Server will redirect output to "+redirect+" code "+code);
188    }
189
190    /**
191     * Returns a map from recently-requested paths (like "/index.html") to a
192     * snapshot of the request data.
193     */
194    public Map<String, Request> pathToRequest() {
195        return pathToRequest;
196    }
197
198    public int getNumAcceptedConnections() {
199        return acceptedConnections;
200    }
201
202    /**
203     * Cause the thread accepting connections on the server socket to close
204     */
205    public void close() {
206        /* Stop the Accept thread */
207        if (acceptT != null) {
208            log("Closing AcceptThread"+acceptT);
209            acceptT.close();
210            acceptT = null;
211        }
212    }
213    /**
214     * The AcceptThread is responsible for initiating worker threads
215     * to handle incoming requests from clients.
216     */
217    class AcceptThread extends Thread {
218
219        ServerSocket ss = null;
220        boolean running = false;
221
222        /**
223         * @param port the port to use, or 0 to let the OS choose.
224         * Hard-coding ports is evil, so always pass 0!
225         */
226        public int init() throws IOException {
227            ss = new ServerSocket(0);
228            ss.setSoTimeout(5000);
229            ss.setReuseAddress(true);
230            return ss.getLocalPort();
231        }
232
233        /**
234         * Main thread responding to new connections
235         */
236        public synchronized void run() {
237            running = true;
238            while (running) {
239                try {
240                    Socket s = ss.accept();
241                    acceptedConnections++;
242                    if (acceptedConnections >= acceptLimit) {
243                        running = false;
244                    }
245                    new Thread(new Worker(s), "additional worker").start();
246                } catch (SocketException e) {
247                    log(e.getMessage());
248                } catch (IOException e) {
249                    log(e.getMessage());
250                }
251            }
252            log("AcceptThread terminated" + this);
253        }
254
255        // Close this socket
256        public void close() {
257            try {
258                running = false;
259                /* Stop server socket from processing further. Currently
260                   this does not cause the SocketException from ss.accept
261                   therefore the acceptLimit functionality has been added
262                   to circumvent this limitation */
263                ss.close();
264            } catch (IOException e) {
265                /* We are shutting down the server, so we expect
266                 * things to die. Don't propagate.
267                 */
268                log("IOException caught by server socket close");
269            }
270        }
271    }
272
273    // Size of buffer for reading from the connection
274    final static int BUF_SIZE = 2048;
275
276    /* End of line byte sequence */
277    static final byte[] EOL = {(byte)'\r', (byte)'\n' };
278
279    /**
280     * An immutable snapshot of an HTTP request.
281     */
282    public static class Request {
283        private final String path;
284        private final Map<String, String> headers;
285        // TODO: include posted content?
286
287        public Request(String path, Map<String, String> headers) {
288            this.path = path;
289            this.headers = new LinkedHashMap<String, String>(headers);
290        }
291
292        public String getPath() {
293            return path;
294        }
295
296        public Map<String, String> getHeaders() {
297            return headers;
298        }
299    }
300
301    /**
302     * The worker thread handles all interactions with a current open
303     * connection. If pipelining is turned on, this will allow this
304     * thread to continuously operate on numerous requests before the
305     * connection is closed.
306     */
307    class Worker implements Support_HttpConstants, Runnable {
308
309        /* buffer to use to hold request data */
310        byte[] buf;
311
312        /* Socket to client we're handling */
313        private Socket s;
314
315        /* Reference to current request method ID */
316        private int requestMethod;
317
318        /* Reference to current requests test file/data */
319        private String testID;
320
321        /* The requested path, such as "/test1" */
322        private String path;
323
324        /* Reference to test number from testID */
325        private int testNum;
326
327        /* Reference to whether new request has been initiated yet */
328        private boolean readStarted;
329
330        /* Indicates whether current request has any data content */
331        private boolean hasContent = false;
332
333        /* Request headers are stored here */
334        private Map<String, String> headers = new LinkedHashMap<String, String>();
335
336        /* Create a new worker thread */
337        Worker(Socket s) {
338            this.buf = new byte[BUF_SIZE];
339            this.s = s;
340        }
341
342        public synchronized void run() {
343            try {
344                handleClient();
345            } catch (Exception e) {
346                log("Exception during handleClient in the TestWebServer: " + e.getMessage());
347            }
348            log(this+" terminated");
349        }
350
351        /**
352         * Zero out the buffer from last time
353         */
354        private void clearBuffer() {
355            for (int i = 0; i < BUF_SIZE; i++) {
356                buf[i] = 0;
357            }
358        }
359
360        /**
361         * Utility method to read a line of data from the input stream
362         * @param is Inputstream to read
363         * @return number of bytes read
364         */
365        private int readOneLine(InputStream is) {
366
367            int read = 0;
368
369            clearBuffer();
370            try {
371                log("Reading one line: started ="+readStarted+" avail="+is.available());
372                StringBuilder log = new StringBuilder();
373                while ((!readStarted) || (is.available() > 0)) {
374                    int data = is.read();
375                    // We shouldn't get EOF but we need tdo check
376                    if (data == -1) {
377                        log("EOF returned");
378                        return -1;
379                    }
380
381                    buf[read] = (byte)data;
382
383                    log.append((char)data);
384
385                    readStarted = true;
386                    if (buf[read++]==(byte)'\n') {
387                        log(log.toString());
388                        return read;
389                    }
390                }
391            } catch (IOException e) {
392                log("IOException from readOneLine");
393            }
394            return read;
395        }
396
397        /**
398         * Read a chunk of data
399         * @param is Stream from which to read data
400         * @param length Amount of data to read
401         * @return number of bytes read
402         */
403        private int readData(InputStream is, int length) {
404            int read = 0;
405            int count;
406            // At the moment we're only expecting small data amounts
407            byte[] buf = new byte[length];
408
409            try {
410                while (is.available() > 0) {
411                    count = is.read(buf, read, length-read);
412                    read += count;
413                }
414            } catch (IOException e) {
415                log("IOException from readData");
416            }
417            return read;
418        }
419
420        /**
421         * Read the status line from the input stream extracting method
422         * information.
423         * @param is Inputstream to read
424         * @return number of bytes read
425         */
426        private int parseStatusLine(InputStream is) {
427            int index;
428            int nread = 0;
429
430            log("Parse status line");
431            // Check for status line first
432            nread = readOneLine(is);
433            // Bomb out if stream closes prematurely
434            if (nread == -1) {
435                requestMethod = UNKNOWN_METHOD;
436                return -1;
437            }
438
439            if (buf[0] == (byte)'G' &&
440                buf[1] == (byte)'E' &&
441                buf[2] == (byte)'T' &&
442                buf[3] == (byte)' ') {
443                requestMethod = GET_METHOD;
444                log("GET request");
445                index = 4;
446            } else if (buf[0] == (byte)'H' &&
447                       buf[1] == (byte)'E' &&
448                       buf[2] == (byte)'A' &&
449                       buf[3] == (byte)'D' &&
450                       buf[4] == (byte)' ') {
451                requestMethod = HEAD_METHOD;
452                log("HEAD request");
453                index = 5;
454            } else if (buf[0] == (byte)'P' &&
455                       buf[1] == (byte)'O' &&
456                       buf[2] == (byte)'S' &&
457                       buf[3] == (byte)'T' &&
458                       buf[4] == (byte)' ') {
459                requestMethod = POST_METHOD;
460                log("POST request");
461                index = 5;
462            } else {
463                // Unhandled request
464                requestMethod = UNKNOWN_METHOD;
465                return -1;
466            }
467
468            // A valid method we understand
469            if (requestMethod > UNKNOWN_METHOD) {
470                // Read file name
471                int i = index;
472                while (buf[i] != (byte)' ') {
473                    // There should be HTTP/1.x at the end
474                    if ((buf[i] == (byte)'\n') || (buf[i] == (byte)'\r')) {
475                        requestMethod = UNKNOWN_METHOD;
476                        return -1;
477                    }
478                    i++;
479                }
480
481                path = new String(buf, 0, index, i-index);
482                testID = path.substring(1);
483
484                return nread;
485            }
486            return -1;
487        }
488
489        /**
490         * Read a header from the input stream
491         * @param is Inputstream to read
492         * @return number of bytes read
493         */
494        private int parseHeader(InputStream is) {
495            int index = 0;
496            int nread = 0;
497            log("Parse a header");
498            // Check for status line first
499            nread = readOneLine(is);
500            // Bomb out if stream closes prematurely
501            if (nread == -1) {
502                requestMethod = UNKNOWN_METHOD;
503                return -1;
504            }
505            // Read header entry 'Header: data'
506            int i = index;
507            while (buf[i] != (byte)':') {
508                // There should be an entry after the header
509
510                if ((buf[i] == (byte)'\n') || (buf[i] == (byte)'\r')) {
511                    return UNKNOWN_METHOD;
512                }
513                i++;
514            }
515
516            String headerName = new String(buf, 0, i);
517            i++; // Over ':'
518            while (buf[i] == ' ') {
519                i++;
520            }
521            String headerValue = new String(buf, i, nread - i - 2); // drop \r\n
522
523            headers.put(headerName, headerValue);
524            return nread;
525        }
526
527        /**
528         * Read all headers from the input stream
529         * @param is Inputstream to read
530         * @return number of bytes read
531         */
532        private int readHeaders(InputStream is) {
533            int nread = 0;
534            log("Read headers");
535            // Headers should be terminated by empty CRLF line
536            while (true) {
537                int headerLen = 0;
538                headerLen = parseHeader(is);
539                if (headerLen == -1)
540                    return -1;
541                nread += headerLen;
542                if (headerLen <= 2) {
543                    return nread;
544                }
545            }
546        }
547
548        /**
549         * Read content data from the input stream
550         * @param is Inputstream to read
551         * @return number of bytes read
552         */
553        private int readContent(InputStream is) {
554            int nread = 0;
555            log("Read content");
556            String lengthString = headers.get(requestHeaders[REQ_CONTENT_LENGTH]);
557            int length = new Integer(lengthString).intValue();
558
559            // Read content
560            length = readData(is, length);
561            return length;
562        }
563
564        /**
565         * The main loop, reading requests.
566         */
567        void handleClient() throws IOException {
568            InputStream is = new BufferedInputStream(s.getInputStream());
569            PrintStream ps = new PrintStream(s.getOutputStream());
570            int nread = 0;
571
572            /* we will only block in read for this many milliseconds
573             * before we fail with java.io.InterruptedIOException,
574             * at which point we will abandon the connection.
575             */
576            s.setSoTimeout(mTimeout);
577            s.setTcpNoDelay(true);
578
579            do {
580                nread = parseStatusLine(is);
581                if (requestMethod != UNKNOWN_METHOD) {
582
583                    // If status line found, read any headers
584                    nread = readHeaders(is);
585
586                    pathToRequest().put(path, new Request(path, headers));
587
588                    // Then read content (if any)
589                    // TODO handle chunked encoding from the client
590                    if (headers.get(requestHeaders[REQ_CONTENT_LENGTH]) != null) {
591                        nread = readContent(is);
592                    }
593                } else {
594                    if (nread > 0) {
595                        /* we don't support this method */
596                        ps.print(HTTP_VERSION_STRING + " " + HTTP_BAD_METHOD +
597                                 " unsupported method type: ");
598                        ps.write(buf, 0, 5);
599                        ps.write(EOL);
600                        ps.flush();
601                    } else {
602                    }
603                    if (!keepAlive || nread <= 0) {
604                        headers.clear();
605                        readStarted = false;
606
607                        log("SOCKET CLOSED");
608                        s.close();
609                        return;
610                    }
611                }
612
613                // Reset test number prior to outputing data
614                testNum = -1;
615
616                // Write out the data
617                printStatus(ps);
618                printHeaders(ps);
619
620                // Write line between headers and body
621                psWriteEOL(ps);
622
623                // Write the body
624                if (redirectCode == -1) {
625                    switch (requestMethod) {
626                        case GET_METHOD:
627                            if ((testNum < -1) || (testNum > Support_TestWebData.tests.length - 1)) {
628                                send404(ps);
629                            } else {
630                                sendFile(ps);
631                            }
632                            break;
633                        case HEAD_METHOD:
634                            // Nothing to do
635                            break;
636                        case POST_METHOD:
637                            // Post method write body data
638                            if ((testNum > 0) || (testNum < Support_TestWebData.tests.length - 1)) {
639                                sendFile(ps);
640                            }
641
642                            break;
643                        default:
644                            break;
645                    }
646                } else { // Redirecting
647                    switch (redirectCode) {
648                        case 301:
649                            // Seems 301 needs a body by neon (although spec
650                            // says SHOULD).
651                            psPrint(ps, Support_TestWebData.testServerResponse[Support_TestWebData.REDIRECT_301]);
652                            break;
653                        case 302:
654                            //
655                            psPrint(ps, Support_TestWebData.testServerResponse[Support_TestWebData.REDIRECT_302]);
656                            break;
657                        case 303:
658                            psPrint(ps, Support_TestWebData.testServerResponse[Support_TestWebData.REDIRECT_303]);
659                            break;
660                        case 307:
661                            psPrint(ps, Support_TestWebData.testServerResponse[Support_TestWebData.REDIRECT_307]);
662                            break;
663                        default:
664                            break;
665                    }
666                }
667
668                ps.flush();
669
670                // Reset for next request
671                readStarted = false;
672                headers.clear();
673
674            } while (keepAlive);
675
676            log("SOCKET CLOSED");
677            s.close();
678        }
679
680        // Print string to log and output stream
681        void psPrint(PrintStream ps, String s) throws IOException {
682            log(s);
683            ps.print(s);
684        }
685
686        // Print bytes to log and output stream
687        void psWrite(PrintStream ps, byte[] bytes, int offset, int count) throws IOException {
688            log(new String(bytes));
689            ps.write(bytes, offset, count);
690        }
691
692        // Print CRLF to log and output stream
693        void psWriteEOL(PrintStream ps) throws IOException {
694            log("CRLF");
695            ps.write(EOL);
696        }
697
698
699        // Print status to log and output stream
700        void printStatus(PrintStream ps) throws IOException {
701            // Handle redirects first.
702            if (redirectCode != -1) {
703                log("REDIRECTING TO "+redirectHost+" status "+redirectCode);
704                psPrint(ps, HTTP_VERSION_STRING + " " + redirectCode +" Moved permanently");
705                psWriteEOL(ps);
706                psPrint(ps, "Location: " + redirectHost);
707                psWriteEOL(ps);
708                return;
709            }
710
711
712            if (testID.startsWith("test")) {
713                testNum = Integer.valueOf(testID.substring(4))-1;
714            }
715
716            if ((testNum < -1) || (testNum > Support_TestWebData.tests.length - 1)) {
717                psPrint(ps, HTTP_VERSION_STRING + " " + HTTP_NOT_FOUND + " not found");
718                psWriteEOL(ps);
719            }  else {
720                psPrint(ps, HTTP_VERSION_STRING + " " + HTTP_OK+" OK");
721                psWriteEOL(ps);
722            }
723
724            log("Status sent");
725        }
726        /**
727         * Create the server response and output to the stream
728         * @param ps The PrintStream to output response headers and data to
729         */
730        void printHeaders(PrintStream ps) throws IOException {
731            if ((testNum < -1) || (testNum > Support_TestWebData.tests.length - 1)) {
732                // 404 status already sent
733                return;
734            }
735            SimpleDateFormat df = new SimpleDateFormat("EE, dd MMM yyyy HH:mm:ss");
736
737            psPrint(ps,"Server: TestWebServer"+mPort);
738            psWriteEOL(ps);
739            psPrint(ps, "Date: " + df.format(new Date()));
740            psWriteEOL(ps);
741            psPrint(ps, "Connection: " + ((keepAlive) ? "Keep-Alive" : "Close"));
742            psWriteEOL(ps);
743
744            // Yuk, if we're not redirecting, we add the file details
745            if (redirectCode == -1) {
746
747                if (testNum == -1) {
748                    if (!Support_TestWebData.test0DataAvailable) {
749                        log("testdata was not initilaized");
750                        return;
751                    }
752                    if (chunked) {
753                        psPrint(ps, "Transfer-Encoding: chunked");
754                    } else {
755                        psPrint(ps, "Content-length: "
756                                + Support_TestWebData.test0Data.length);
757                    }
758                    psWriteEOL(ps);
759
760                    psPrint(ps, "Last Modified: " + (new Date(
761                            Support_TestWebData.test0Params.testLastModified)));
762                    psWriteEOL(ps);
763
764                    psPrint(ps, "Content-type: "
765                            + Support_TestWebData.test0Params.testType);
766                    psWriteEOL(ps);
767
768                    if (Support_TestWebData.testParams[testNum].testExp > 0) {
769                        long exp;
770                        exp = Support_TestWebData.testParams[testNum].testExp;
771                        psPrint(ps, "expires: "
772                                + df.format(exp) + " GMT");
773                        psWriteEOL(ps);
774                    }
775                } else if (!Support_TestWebData.testParams[testNum].testDir) {
776                    if (chunked) {
777                        psPrint(ps, "Transfer-Encoding: chunked");
778                    } else {
779                        psPrint(ps, "Content-length: "+Support_TestWebData.testParams[testNum].testLength);
780                    }
781                    psWriteEOL(ps);
782
783                    psPrint(ps,"Last Modified: " + (new
784                                                    Date(Support_TestWebData.testParams[testNum].testLastModified)));
785                    psWriteEOL(ps);
786
787                    psPrint(ps, "Content-type: " + Support_TestWebData.testParams[testNum].testType);
788                    psWriteEOL(ps);
789
790                    if (Support_TestWebData.testParams[testNum].testExp > 0) {
791                        long exp;
792                        exp = Support_TestWebData.testParams[testNum].testExp;
793                        psPrint(ps, "expires: "
794                                + df.format(exp) + " GMT");
795                        psWriteEOL(ps);
796                    }
797                } else {
798                    psPrint(ps, "Content-type: text/html");
799                    psWriteEOL(ps);
800                }
801            } else {
802                // Content-length of 301, 302, 303, 307 are the same.
803                psPrint(ps, "Content-length: "+(Support_TestWebData.testServerResponse[Support_TestWebData.REDIRECT_301]).length());
804                psWriteEOL(ps);
805                psWriteEOL(ps);
806            }
807            log("Headers sent");
808
809        }
810
811        /**
812         * Sends the 404 not found message
813         * @param ps The PrintStream to write to
814         */
815        void send404(PrintStream ps) throws IOException {
816            ps.println("Not Found\n\n"+
817                       "The requested resource was not found.\n");
818        }
819
820        /**
821         * Sends the data associated with the headers
822         * @param ps The PrintStream to write to
823         */
824        void sendFile(PrintStream ps) throws IOException {
825            if (testNum == -1) {
826                if (!Support_TestWebData.test0DataAvailable) {
827                    log("test data was not initialized");
828                    return;
829                }
830                sendFile(ps, Support_TestWebData.test0Data);
831            } else {
832                sendFile(ps, Support_TestWebData.tests[testNum]);
833            }
834        }
835
836        void sendFile(PrintStream ps, byte[] bytes) throws IOException {
837            if (chunked) {
838                int offset = 0;
839                while (offset < bytes.length) {
840                    int chunkSize = Math.min(bytes.length - offset, maxChunkSize);
841                    psPrint(ps, Integer.toHexString(chunkSize));
842                    psWriteEOL(ps);
843                    psWrite(ps, bytes, offset, chunkSize);
844                    psWriteEOL(ps);
845                    offset += chunkSize;
846                }
847                psPrint(ps, "0");
848                psWriteEOL(ps);
849                psWriteEOL(ps);
850            } else {
851                psWrite(ps, bytes, 0, bytes.length);
852            }
853        }
854    }
855}
856