diff options
55 files changed, 5119 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da7576a --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +/GIT-VERSION-FILE +/NEWS +/pkg +/lib/yahns/version.rb +/coverage.dump +*.tar.gz +*.log +/man +*.gem +*.gz +.manifest +.gem-manifest +.tgz-manifest @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<http://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<http://www.gnu.org/philosophy/why-not-lgpl.html>. diff --git a/GIT-VERSION-GEN b/GIT-VERSION-GEN new file mode 100755 index 0000000..458b1c2 --- /dev/null +++ b/GIT-VERSION-GEN @@ -0,0 +1,41 @@ +#!/usr/bin/env ruby +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +CONSTANT = "Yahns::VERSION" +RVF = "lib/yahns/version.rb" +GVF = "GIT-VERSION-FILE" +DEF_VER = "v0.0.0" +vn = DEF_VER + +# First see if there is a version file (included in release tarballs), +# then try git-describe, then default. +if File.exist?(".git") + describe = `git describe --abbrev=4 HEAD 2>/dev/null`.strip + case describe + when /\Av[0-9]*/ + vn = describe + system(*%w(git update-index -q --refresh)) + unless `git diff-index --name-only HEAD --`.chomp.empty? + vn << "-dirty" + end + vn.tr!('-', '.') + end +end + +vn = vn.sub!(/\Av/, "") +new_ruby_version = "#{CONSTANT} = '#{vn}' # :nodoc:\n" +cur_ruby_version = File.read(RVF) rescue nil +if new_ruby_version != cur_ruby_version + File.open(RVF, "w") { |fp| fp.write(new_ruby_version) } +end +File.chmod(0644, RVF) + +# generate the makefile snippet +new_make_version = "VERSION = #{vn}\n" +cur_make_version = File.read(GVF) rescue nil +if new_make_version != cur_make_version + File.open(GVF, "w") { |fp| fp.write(new_make_version) } +end +File.chmod(0644, GVF) + +puts vn if $0 == __FILE__ diff --git a/GNUmakefile b/GNUmakefile new file mode 100644 index 0000000..f0f8cdf --- /dev/null +++ b/GNUmakefile @@ -0,0 +1,90 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +all:: +pkg = yahns +RUBY = ruby +GIT-VERSION-FILE: .FORCE-GIT-VERSION-FILE + @./GIT-VERSION-GEN +-include GIT-VERSION-FILE +lib := lib + +all:: test +test_units := $(wildcard test/test_*.rb) +test: $(test_units) +$(test_units): + $(RUBY) -w -I $(lib) $@ -v + +test-mt: export N = $(shell nproc 2>/dev/null || echo 4) +test-mt: + $(RUBY) -w -I $(lib) $(addprefix -r./,$(test_units)) -eexit -- + +check-warnings: + @(for i in $$(git ls-files '*.rb'| grep -v '^setup\.rb$$'); \ + do $(RUBY) -d -W2 -c $$i; done) | grep -v '^Syntax OK$$' || : + +check: test +coverage: export COVERAGE=1 +coverage: + > coverage.dump + $(MAKE) check + $(RUBY) ./test/covshow.rb + +coverage-mt: export COVERAGE=1 +coverage-mt: + > coverage.dump + $(MAKE) test-mt + $(RUBY) ./test/covshow.rb + +pkggem := pkg/$(pkg)-$(VERSION).gem +pkgtgz := pkg/$(pkg)-$(VERSION).tar.gz + +fix-perms: + git ls-tree -r HEAD | awk '/^100644 / {print $$NF}' | xargs chmod 644 + git ls-tree -r HEAD | awk '/^100755 / {print $$NF}' | xargs chmod 755 + +gem: $(pkggem) + +install-gem: $(pkggem) + gem install $(CURDIR)/$< + +$(pkggem): .gem-manifest + VERSION=$(VERSION) gem build $(pkg).gemspec + mkdir -p pkg + mv $(@F) $@ + +pkg_extra := GIT-VERSION-FILE lib/yahns/version.rb NEWS +NEWS: + rake -s $@ + +gem-man: + $(MAKE) -C Documentation/ gem-man +tgz-man: + $(MAKE) -C Documentation/ install-man mandir=$(CURDIR)/man +.PHONY: tgz-man gem-man + +.gem-manifest: .manifest + # (ls man/*.?; cat .manifest) | LC_ALL=C sort > $@+ + LC_ALL=C sort < .manifest > $@+ + cmp $@+ $@ || mv $@+ $@; rm -f $@+ +.tgz-manifest: .manifest + LC_ALL=C sort < .manifest > $@+ + cmp $@+ $@ || mv $@+ $@; rm -f $@+ +.manifest: NEWS fix-perms + rm -rf man + (git ls-files; \ + for i in $(pkg_extra); do echo $$i; done) | \ + LC_ALL=C sort > $@+ + cmp $@+ $@ || mv $@+ $@; rm -f $@+ +$(pkgtgz): distdir = pkg/$(pkg)-$(VERSION) +$(pkgtgz): .tgz-manifest + @test -n "$(distdir)" + $(RM) -r $(distdir) + mkdir -p $(distdir) + tar cf - $$(cat .tgz-manifest) | (cd $(distdir) && tar xf -) + cd pkg && tar cf - $(pkg)-$(VERSION) | gzip -9 > $(@F)+ + mv $@+ $@ + +package: $(pkgtgz) $(pkggem) + +.PHONY: all .FORCE-GIT-VERSION-FILE test $(test_units) NEWS +.PHONY: check-warnings fix-perms @@ -0,0 +1,127 @@ +yahns - sleepy, multi-threaded, non-blocking application server for Ruby +------------------------------------------------------------------------ + +A Free Software, multi-threaded, non-blocking network application server +designed for low _idle_ power consumption. It is primarily optimized +for applications with occasional users which see little or no traffic. +yahns currently hosts Rack/HTTP applications, but may eventually support +other application types. Unlike some existing servers, yahns is +extremely sensitive to fatal bugs in the applications it hosts. + +Features +-------- + +* _zero_ wakeups when all clients are idle +* idle client connections may live forever if there is no FD pressure +* suitable for slow clients, fast clients, or a mixture of both +* HTTP/0.9 support +* HTTP/1.1 persistent connections and pipelining +* decodes HTTP chunked encoding for requests +* parses HTTP/1.1 trailers in requests +* supports streaming responses with lazy buffering for slow clients +* optional streaming input for fast clients +* able to host multiple applications with different settings +* uses epoll to scale to many idle connections +* abuses epoll as a load balancer between threads inside a process +* optional multi-process support (in addition to threads) +* fairly balances new clients between multiple processes (on Linux) + +Supported Platforms +------------------- + +yahns is developed primarily for modern GNU/Linux systems. + +We may support kqueue for FreeBSD/OpenBSD/NetBSD if there is significant +interest. Non-Free systems/dependencies will never be supported + +Supported Ruby implementations: +* (Matz) Ruby 2.0 or later +* Rubinius 2.0 or later (planned) + +Contact +------- + +We are happy to see feedback of all types via plain-text email. +Please send comments, user/dev discussion, patches, bug reports, +and pull requests to Eric Wong at: normalperson@yhbt.net + +Public mailing list coming soon. + +This README is our homepage, we would rather be working on HTTP servers +all day than worrying about the next browser vulnerability because +HTML/CSS/JS is too complicated for us. + +* http://yahns.yhbt.net/README + +Hacking +------- + +We use git and follow the same development model as git itself +(mailing list-oriented, benevolent dictator). + + git clone git://yahns.yhbt.net/yahns + +Please use git-format-patch(1) and git-send-email(1) distributed with +the git(7) suite for generating and sending patches. Please format +pull requests with the git-request-pull(1) script (also distributed +with git(7)) and send them via email. + +See http://www.git-scm.com/ for more information on git. + +Design +------ + +yahns is designed to optimimally use multiple threads with non-blocking I/O. +The event loop is not a traditional single-threaded design with a mutex +slapped on as an afterthought, but designed from the beginning to utilize +multiple threads. + +* two classes of long-lived, persistent threads + 1. blocking acceptors + 2. non-blocking event loop workers +* epoll acts as a queue (by using one-shot notifications) +* acceptors accept new clients and put them in the epoll "queue" +* workers pull clients off the queue, rearming them to epoll on EAGAIN + +The end result is clients transition freely and fairly between threads +and will always be able to find the next idle thread to run on. + +This design works with kqueue, too, and we will support kqueue if there +is interest. In fact, got our design inspiration from the name "kqueue" +when working on another project. We may also support libkqueue: + + http://sourceforge.net/projects/libkqueue/ + +In addition to multiple threads, yahns optionally supports multiple +processes to work around low FD limits as well as contention in the: + +* kernel (socket (de)allocation from accept/close) +* standard C library (malloc/free) +* Ruby VM (GVL, GC) +* application it hosts + +Copyright +--------- + +Copyright 2013, Eric Wong <normalperson@yhbt.net> and all contributors. +License: GPLv3 or later <https://www.gnu.org/licenses/gpl-3.0.txt> + +yahns is copyrighted Free Software by all contributors, see logs in +revision control for names and email addresses of all of them. yahns +contains code from Mongrel, unicorn, and Rainbows! which may also be +licensed under the GPLv2 or later. + +yahns is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3 of the License, or (at your +option) any later version. + +yahns is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License along +with this program; if not, see https://www.gnu.org/licenses/gpl-3.0.txt + +lrg nabgure ubeevoyl-anzrq freire :> diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..5d36835 --- /dev/null +++ b/Rakefile @@ -0,0 +1,60 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require 'tempfile' +include Rake::DSL +task "NEWS" do + latest = nil + fp = Tempfile.new("NEWS", ".") + fp.sync = true + `git tag -l`.split(/\n/).reverse.each do |tag| + %r{\Av(.+)} =~ tag or next + version = $1 + header, subject, body = `git cat-file tag #{tag}`.split(/\n\n/, 3) + header = header.split(/\n/) + tagger = header.grep(/\Atagger /)[0] + time = Time.at(tagger.split(/ /)[-2].to_i).utc + latest ||= time + date = time.strftime("%Y-%m-%d") + fp.puts "# #{version} / #{date}\n\n#{subject}" + if body && body.strip.size > 0 + fp.puts "\n\n#{body}" + end + fp.puts + end + fp.write("Unreleased\n\n") unless fp.size > 0 + fp.puts "# COPYRIGHT" + bdfl = 'Eric Wong <normalperson@yhbt.net>' + fp.puts "Copyright (C) 2013, #{bdfl} and all contributors" + fp.puts "License: GPLv3 or later (http://www.gnu.org/licenses/gpl-3.0.txt)" + fp.rewind + assert_equal fp.read, File.read("NEWS") rescue nil + fp.chmod 0644 + File.rename(fp.path, "NEWS") + fp.close! +end + +task rsync_docs: "NEWS" do + dest = ENV["RSYNC_DEST"] || "yahns.yhbt.net:/srv/yahns/" + top = %w(NEWS README COPYING) + files = [] + + # git-set-file-times is distributed with rsync, + # Also available at: http://yhbt.net/git-set-file-times + # on Debian systems: /usr/share/doc/rsync/scripts/git-set-file-times.gz + sh("git", "set-file-times", "examples", *top) + + `git ls-files Documentation/*.txt`.split(/\n/).concat(top).each do |txt| + gz = "#{txt}.gz" + tmp = "#{gz}.#$$" + sh("gzip -9 < #{txt} > #{tmp}") + st = File.stat(txt) + File.utime(st.atime, st.mtime, tmp) # make nginx gzip_static happy + File.rename(tmp, gz) + files << txt + files << gz + end + sh("rsync --chmod=Fugo=r -av #{files.join(' ')} #{dest}") + + examples = `git ls-files examples`.split("\n") + sh("rsync --chmod=Fugo=r -av #{examples.join(' ')} #{dest}/examples/") +end diff --git a/bin/yahns b/bin/yahns new file mode 100755 index 0000000..5c4d34e --- /dev/null +++ b/bin/yahns @@ -0,0 +1,32 @@ +#!/this/will/be/overwritten/or/wrapped/anyways/do/not/worry/ruby +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +$stdout.sync = $stderr.sync = true +require 'yahns' +require 'optparse' +config_file = daemonize = nil +OptionParser.new("", 24, " ") do |opts| + cmd = File.basename($0) + opts.banner = "Usage: #{cmd} [options]" + opts.separator "#{cmd} options:" + opts.on("-D", "--daemonize", "run daemonized in the background") do |d| + daemonize = !!d + end + opts.on("-c", "--config-file FILE", "yahns config file") do |f| + config_file = f + end + opts.separator "Common options:" + opts.on_tail("-h", "--help", "Show this message") do + puts opts.to_s + exit + end + opts.on_tail("-v", "--version", "Show version") do + puts "#{cmd} v#{Yahns::VERSION}" + exit + end + opts.parse!(ARGV) +end +server = Yahns::Server.new(Yahns::Config.new(config_file)) +Yahns::Daemon.daemon(server) if daemonize +server.start.join diff --git a/examples/README b/examples/README new file mode 100644 index 0000000..c87947a --- /dev/null +++ b/examples/README @@ -0,0 +1,3 @@ +All files in this example directory (including this one) are CC0: +To the extent possible under law, Eric Wong has waived all copyright and +related or neighboring rights to these examples. diff --git a/examples/init.sh b/examples/init.sh new file mode 100644 index 0000000..9464220 --- /dev/null +++ b/examples/init.sh @@ -0,0 +1,76 @@ +#!/bin/sh +# To the extent possible under law, Eric Wong has waived all copyright and +# related or neighboring rights to this examples +set -e +# Example init script, this can be used with nginx, too, +# since nginx and yahns accept the same signals + +# Feel free to change any of the following variables for your app: +TIMEOUT=${TIMEOUT-60} +APP_ROOT=/home/x/my_app/current +PID=$APP_ROOT/tmp/pids/yahns.pid +CMD="/usr/bin/yahns -D -c $APP_ROOT/config/yahns.rb" +INIT_CONF=$APP_ROOT/config/init.conf +action="$1" +set -u + +test -f "$INIT_CONF" && . $INIT_CONF + +old_pid="$PID.oldbin" + +cd $APP_ROOT || exit 1 + +sig () { + test -s "$PID" && kill -$1 `cat $PID` +} + +oldsig () { + test -s $old_pid && kill -$1 `cat $old_pid` +} + +case $action in +start) + sig 0 && echo >&2 "Already running" && exit 0 + $CMD + ;; +stop) + sig QUIT && exit 0 + echo >&2 "Not running" + ;; +force-stop) + sig TERM && exit 0 + echo >&2 "Not running" + ;; +restart|reload) + sig HUP && echo reloaded OK && exit 0 + echo >&2 "Couldn't reload, starting '$CMD' instead" + $CMD + ;; +upgrade) + if sig USR2 && sleep 2 && sig 0 && oldsig QUIT + then + n=$TIMEOUT + while test -s $old_pid && test $n -ge 0 + do + printf '.' && sleep 1 && n=$(( $n - 1 )) + done + echo + + if test $n -lt 0 && test -s $old_pid + then + echo >&2 "$old_pid still exists after $TIMEOUT seconds" + exit 1 + fi + exit 0 + fi + echo >&2 "Couldn't upgrade, starting '$CMD' instead" + $CMD + ;; +reopen-logs) + sig USR1 + ;; +*) + echo >&2 "Usage: $0 <start|stop|restart|upgrade|force-stop|reopen-logs>" + exit 1 + ;; +esac diff --git a/examples/logger_mp_safe.rb b/examples/logger_mp_safe.rb new file mode 100644 index 0000000..569d661 --- /dev/null +++ b/examples/logger_mp_safe.rb @@ -0,0 +1,28 @@ +# To the extent possible under law, Eric Wong has waived all copyright and +# related or neighboring rights to this examples +# +# Multi-Processing-safe monkey patch for Logger +# +# This monkey patch fixes the case where "preload: true" is used and +# the application spawns a background thread upon being loaded. +# +# This removes all lock from the Logger code and solely relies on the +# underlying filesystem to handle write(2) system calls atomically when +# O_APPEND is used. This is safe in the presence of both multiple +# threads (native or green) and multiple processes when writing to +# a filesystem with POSIX O_APPEND semantics. +# +# It should be noted that the original locking on Logger could _never_ be +# considered reliable on non-POSIX filesystems with multiple processes, +# either, so nothing is lost in that case. + +require 'logger' +class Logger::LogDevice + def write(message) + @dev.syswrite(message) + end + + def close + @dev.close + end +end diff --git a/examples/logrotate.conf b/examples/logrotate.conf new file mode 100644 index 0000000..ebc92a5 --- /dev/null +++ b/examples/logrotate.conf @@ -0,0 +1,32 @@ +# To the extent possible under law, Eric Wong has waived all copyright and +# related or neighboring rights to this examples +# +# example logrotate config file, I usually keep this in +# /etc/logrotate.d/yahns_app on my Debian systems +# +# See the logrotate(8) manpage for more information: +# http://linux.die.net/man/8/logrotate + +# Modify the following glob to match the logfiles your app writes to: +/var/log/yahns_app/*.log { + # this first block is mostly just personal preference, though + # I wish logrotate offered an "hourly" option... + daily + missingok + rotate 180 + compress # must use with delaycompress below + dateext + + # this is important if using "compress" since we need to call + # the "lastaction" script below before compressing: + delaycompress + + # note the lack of the evil "copytruncate" option in this + # config. yahns supports the USR1 signal and we send it + # as our "lastaction" action: + lastaction + # assuming your pid file is in /var/run/yahns_app/pid + pid=/var/run/yahns_app/pid + test -s $pid && kill -USR1 "$(cat $pid)" + endscript +} diff --git a/examples/yahns_multi.conf.rb b/examples/yahns_multi.conf.rb new file mode 100644 index 0000000..cdcb445 --- /dev/null +++ b/examples/yahns_multi.conf.rb @@ -0,0 +1,89 @@ +# To the extent possible under law, Eric Wong has waived all copyright and +# related or neighboring rights to this example. + +# By default, this based on the soft limit of RLIMIT_NOFILE +# count = Process.getrlimit(:NOFILE)[0]) * 0.5 +# yahns will start expiring idle clients once we hit it +client_expire_threshold 0.5 + +# each queue definition configures a thread pool and epoll_wait usage +# The default queue is always present +queue(:default) do + worker_threads 7 # this is the default value + max_events 1 # 1: fairest, best in all multi-threaded cases +end + +# This is an example of a small queue with fewer workers and unfair scheduling. +# It is rarely necessary or even advisable to configure multiple queues. +queue(:small) do + worker_threads 2 + + # increase max_events only under one of the following circumstances: + # 1) worker_threads is 1 + # 2) epoll_wait lock contention inside the kernel is the biggest bottleneck + # (this is unlikely outside of "hello world" apps) + max_events 64 +end + +# This is an example of a Rack application configured in yahns +# All values below are defaults +app(:rack, "/path/to/config.ru", preload: false) do + listen 8080, backlog: 1024, tcp_nodelay: false + client_max_body_size 1024*1024 + check_client_connection false + logger Logger.new($stderr) + client_timeout 15 + input_buffering true + output_buffering true # output buffering is always lazy if enabled + persistent_connections true + errors $stderr + queue :default +end + +# same as first, just listen on different port and small queue +app(:rack, "/path/to/config.ru") do + listen "10.0.0.1:10000" + client_max_body_size 1024*1024*10 + check_client_connection true + logger Logger.new("/path/to/another/log") + client_timeout 30 + persistent_connections true + errors "/path/to/errors.log" + queue :small +end + +# totally different app +app(:rack, "/path/to/another.ru", preload: true) do + listen 8081, sndbuf: 1024 * 1024 + listen "/path/to/unix.sock" + client_max_body_size 1024*1024*1024 + input_buffering :lazy + output_buffering false + client_timeout 4 + persistent_connections false + + # different apps may share the same queue, but listen on different ports. + queue :default +end + +# yet another totally different app, this app is not-thread safe but fast +# enough for multi-process to not matter. +# Use unicorn if you need multi-process performance on single-threaded apps +app(:rack, "/path/to/not_thread_safe.ru") do + # listeners _always_ get a private thread in yahns + listen "/path/to/yet_another.sock" + listen 8082 + + # inline private queue definition here + queue do + worker_threads 1 # single-threaded queue + max_events 64 # very high max_events is perfectly fair for single thread + end + + # single (or few)-threaded apps must use full buffering if serving + # untrusted/slow clients. + input_buffering true + output_buffering true +end +# Note: this file is used by test_config.rb, be sure to update that +# if we update this diff --git a/examples/yahns_rack_basic.conf.rb b/examples/yahns_rack_basic.conf.rb new file mode 100644 index 0000000..ea367cd --- /dev/null +++ b/examples/yahns_rack_basic.conf.rb @@ -0,0 +1,27 @@ +# To the extent possible under law, Eric Wong has waived all copyright and +# related or neighboring rights to this examples +# A typical Rack example for hosting a single Rack application with yahns +# and only frequently-useful config values + +worker_processes 1 +# working_directory "/path/to/my_app" +stdout_path "/path/to/my_logs/out.log" +stderr_path "/path/to/my_logs/err.log" +pid "/path/to/my_pids/yahns.pid" +client_expire_threshold 0.5 + +queue do + worker_threads 50 +end + +app(:rack, "config.ru", preload: false) do + listen 8080 + client_max_body_size 1024 * 1024 + input_buffering true + output_buffering true # this lazy by default + client_timeout 5 + persistent_connections true +end + +# Note: this file is used by test_config.rb, be sure to update that +# if we update this diff --git a/lib/yahns.rb b/lib/yahns.rb new file mode 100644 index 0000000..373fca0 --- /dev/null +++ b/lib/yahns.rb @@ -0,0 +1,66 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require 'unicorn' # pulls in raindrops, kgio, fcntl, etc, stringio, and logger +require 'sleepy_penguin' + +# yahns exposes no user-visible API outside of the config file +# Internals are subject to change. +module Yahns # :nodoc: + # We populate this at startup so we can figure out how to reexecute + # and upgrade the currently running instance of yahns + # Unlike unicorn, this Hash is NOT a stable/public interface. + # + # * 0 - the path to the yahns executable + # * :argv - a deep copy of the ARGV array the executable originally saw + # * :cwd - the working directory of the application, this is where + # you originally started yahns. + # + # To change your yahns executable to a different path without downtime, + # you can set the following in your yahns config file, HUP and then + # continue with the traditional USR2 + QUIT upgrade steps: + # + # Yahns::START[0] = "/home/bofh/2.0.0/bin/yahns" + START = { + :argv => ARGV.map { |arg| arg.dup }, + 0 => $0.dup, + } + + # We favor ENV['PWD'] since it is (usually) symlink aware for Capistrano + # and like systems + START[:cwd] = begin + a = File.stat(pwd = ENV['PWD']) + b = File.stat(Dir.pwd) + a.ino == b.ino && a.dev == b.dev ? pwd : Dir.pwd + rescue + Dir.pwd + end + + # Raised inside TeeInput when a client closes the socket inside the + # application dispatch. This is always raised with an empty backtrace + # since there is nothing in the application stack that is responsible + # for client shutdowns/disconnects. + class ClientShutdown < EOFError # :nodoc: + end +end + +# FIXME: require lazily +require_relative 'yahns/log' +require_relative 'yahns/queue_epoll' +require_relative 'yahns/stream_input' +require_relative 'yahns/tee_input' +require_relative 'yahns/queue_egg' +require_relative 'yahns/client_expire' +require_relative 'yahns/http_response' +require_relative 'yahns/http_client' +require_relative 'yahns/http_context' +require_relative 'yahns/queue' +require_relative 'yahns/config' +require_relative 'yahns/tmpio' +require_relative 'yahns/worker' +require_relative 'yahns/sigevent' +require_relative 'yahns/daemon' +require_relative 'yahns/socket_helper' +require_relative 'yahns/server' +require_relative 'yahns/fdmap' +require_relative 'yahns/acceptor' +require_relative 'yahns/wbuf' diff --git a/lib/yahns/acceptor.rb b/lib/yahns/acceptor.rb new file mode 100644 index 0000000..b5e7b0e --- /dev/null +++ b/lib/yahns/acceptor.rb @@ -0,0 +1,28 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> et. al. +# License: GPLv3 or later (see COPYING for details) +module Yahns::Acceptor # :nodoc: + def spawn_acceptor(logger, client_class, queue) + accept_flags = Kgio::SOCK_NONBLOCK | Kgio::SOCK_CLOEXEC + Thread.new do + Thread.current.abort_on_exception = true + qev_flags = client_class.superclass::QEV_FLAGS + begin + # We want the accept/accept4 syscall to be _blocking_ + # so it can distribute work evenly between processes + if client = kgio_accept(client_class, accept_flags) + client.yahns_init + + # it is not safe to touch client in this thread after this, + # a worker thread may grab client right away + queue.queue_add(client, qev_flags) + end + rescue Errno::EMFILE, Errno::ENFILE => e + logger.error("#{e.message}, consider raising open file limits") + queue.fdmap.desperate_expire_for(self, 5) + sleep 1 # let other threads do some work + rescue => e + Yahns::Log.exception(logger, "accept loop error", e) unless closed? + end until closed? + end + end +end diff --git a/lib/yahns/client_expire.rb b/lib/yahns/client_expire.rb new file mode 100644 index 0000000..7da9498 --- /dev/null +++ b/lib/yahns/client_expire.rb @@ -0,0 +1,40 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) + +# included in Yahns::HttpClient +# +# this provides the ability to expire idle clients once we hit a soft limit +# on idle clients +# +# we absolutely DO NOT issue IO#close in here, only BasicSocket#shutdown +module Yahns::ClientExpire # :nodoc: + def yahns_expire(timeout) # rarely called + return 0 if closed? # still racy, but avoid the exception in most cases + + info = Raindrops::TCP_Info.new(self) + return 0 if info.tcpi_state != 1 # TCP_ESTABLISHED == 1 + + # Linux struct tcp_info timers are in milliseconds + timeout *= 1000 + + send_timedout = !!(info.tcpi_last_data_sent > timeout) + + # tcpi_last_data_recv is not valid unless tcpi_ato (ACK timeout) is set + if 0 == info.tcpi_ato + sd = send_timedout && (info.tcpi_last_ack_recv > timeout) + else + sd = send_timedout && (info.tcpi_last_data_recv > timeout) + end + if sd + shutdown + 1 + else + 0 + end + # we also do not expire UNIX domain sockets + # (since those are the most trusted of local clients) + # the IO#closed? check is racy + rescue + 0 + end +end diff --git a/lib/yahns/client_expire_portable.rb b/lib/yahns/client_expire_portable.rb new file mode 100644 index 0000000..2ea7706 --- /dev/null +++ b/lib/yahns/client_expire_portable.rb @@ -0,0 +1,39 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +module Yahns::ClientExpire # :nodoc: + def __timestamp + Time.now.to_f + end + + def yahns_expire(timeout) + return 0 if closed? # still racy, but avoid the exception in most cases + if (__timestamp - @last_io_at) > timeout + shutdown + 1 + else + 0 + end + rescue # the IO#closed? check is racy + 0 + end + + def kgio_read(*args) + @last_io_at = __timestamp + super + end + + def kgio_write(*args) + @last_io_at = __timestamp + super + end + + def kgio_trywrite(*args) + @last_io_at = __timestamp + super + end + + def kgio_tryread(*args) + @last_io_at = __timestamp + super + end +end diff --git a/lib/yahns/config.rb b/lib/yahns/config.rb new file mode 100644 index 0000000..1d4d110 --- /dev/null +++ b/lib/yahns/config.rb @@ -0,0 +1,341 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +# +# Implements a DSL for configuring a yahns server. +# See http://yahns.yhbt.net/examples/yahns_multi.conf.rb for a full +# example configuration file. +class Yahns::Config # :nodoc: + APP_CLASS = {} # public, see yahns/rack for usage example + CfgBlock = Struct.new(:type, :ctx) # :nodoc: + attr_reader :config_file, :config_listeners, :set + attr_reader :qeggs, :app_ctx + + def initialize(config_file = nil) + @config_file = config_file + @block = nil + config_reload! + end + + def _check_in_block(ctx, var) + if ctx == nil + return var if @block == nil + msg = "#{var} must be called outside of #{@block.type}" + else + return var if @block && ctx == @block.type + msg = @block ? "may not be used inside a #{@block.type} block" : + "must be used with a #{ctx} block" + end + raise ArgumentError, msg + end + + def config_reload! #:nodoc: + # app_instance:app_ctx is a 1:N relationship + @config_listeners = {} # name/address -> options + @app_ctx = [] + @set = Hash.new(:unset) + @qeggs = {} + @app_instances = {} + + # set defaults: + client_expire_threshold(0.5) # default is half of the open file limit + + instance_eval(File.read(@config_file), @config_file) if @config_file + + # working_directory binds immediately (easier error checking that way), + # now ensure any paths we changed are correctly set. + [ :pid, :stderr_path, :stdout_path ].each do |var| + String === (path = @set[var]) or next + path = File.expand_path(path) + File.writable?(path) || File.writable?(File.dirname(path)) or \ + raise ArgumentError, "directory for #{var}=#{path} not writable" + end + end + + def logger(obj) + var = :logger + %w(debug info warn error fatal).each do |m| + obj.respond_to?(m) and next + raise ArgumentError, "#{var}=#{obj} does not respond to method=#{m}" + end + if @block + if @block.ctx.respond_to?(:logger=) + @block.ctx.logger = obj + else + raise ArgumentError, "#{var} not valid inside #{@block.type}" + end + else + @set[var] = obj + end + end + + def worker_processes(nr) + # TODO: allow zero + var = _check_in_block(nil, :worker_processes) + @set[var] = _check_int(var, nr, 1) + end + + # sets the +path+ for the PID file of the yahns master process + def pid(path) + _set_path(:pid, path) + end + + def stderr_path(path) + _set_path(:stderr_path, path) + end + + def stdout_path(path) + _set_path(:stdout_path, path) + end + + def value(var) + val = @set[var] + val == :unset ? nil : val + end + + # sets the working directory for yahns. This ensures SIGUSR2 will + # start a new instance of yahns in this directory. This may be + # a symlink, a common scenario for Capistrano users. Unlike + # all other yahns configuration directives, this binds immediately + # for error checking and cannot be undone by unsetting it in the + # configuration file and reloading. + def working_directory(path) + var = :working_directory + @app_ctx.empty? or + raise ArgumentError, "#{var} must be declared before any apps" + + # just let chdir raise errors + path = File.expand_path(path) + if @config_file && + @config_file[0] != ?/ && + ! File.readable?("#{path}/#@config_file") + raise ArgumentError, + "config_file=#@config_file would not be accessible in" \ + " #{var}=#{path}" + end + Dir.chdir(path) + @set[var] = ENV["PWD"] = path + end + + # Runs worker processes as the specified +user+ and +group+. + # The master process always stays running as the user who started it. + # This switch will occur after calling the after_fork hooks, and only + # if the Worker#user method is not called in the after_fork hooks + # +group+ is optional and will not change if unspecified. + def user(user, group = nil) + var = :user + @block and raise "#{var} is not valid inside #{@block.type}" + # raises ArgumentError on invalid user/group + Etc.getpwnam(user) + Etc.getgrnam(group) if group + @set[var] = [ user, group ] + end + + def _set_path(var, path) #:nodoc: + _check_in_block(nil, var) + case path + when NilClass, String + @set[var] = path + else + raise ArgumentError + end + end + + def listen(address, options = {}) + options = options.dup + var = _check_in_block(:app, :listen) + address = expand_addr(address) + String === address or + raise ArgumentError, "address=#{address.inspect} must be a string" + [ :umask, :backlog, :sndbuf, :rcvbuf ].each do |key| + value = options[key] or next + Integer === value or + raise ArgumentError, "#{var}: not an integer: #{key}=#{value.inspect}" + end + [ :ipv6only ].each do |key| + (value = options[key]).nil? and next + [ true, false ].include?(value) or + raise ArgumentError, "#{var}: not boolean: #{key}=#{value.inspect}" + end + + options[:yahns_app_ctx] = @block.ctx + @config_listeners.include?(address) and + raise ArgumentError, "listen #{address} already in use" + @config_listeners[address] = options + end + + # expands "unix:path/to/foo" to a socket relative to the current path + # expands pathnames of sockets if relative to "~" or "~username" + # expands "*:port and ":port" to "0.0.0.0:port" + def expand_addr(address) #:nodoc: + return "0.0.0.0:#{address}" if Integer === address + return address unless String === address + + case address + when %r{\Aunix:(.*)\z} + File.expand_path($1) + when %r{\A~} + File.expand_path(address) + when %r{\A(?:\*:)?(\d+)\z} + "0.0.0.0:#$1" + when %r{\A\[([a-fA-F0-9:]+)\]\z}, %r/\A((?:\d+\.){3}\d+)\z/ + canonicalize_tcp($1, 80) + when %r{\A\[([a-fA-F0-9:]+)\]:(\d+)\z}, %r{\A(.*):(\d+)\z} + canonicalize_tcp($1, $2.to_i) + else + address + end + end + + def canonicalize_tcp(addr, port) + packed = Socket.pack_sockaddr_in(port, addr) + port, addr = Socket.unpack_sockaddr_in(packed) + /:/ =~ addr ? "[#{addr}]:#{port}" : "#{addr}:#{port}" + end + + def queue(name = :default, &block) + var = :queue + qegg = @qeggs[name] ||= Yahns::QueueEgg.new + prev_block = @block + _check_in_block(:app, var) if prev_block + if block_given? + @block = CfgBlock.new(:queue, qegg) + instance_eval(&block) + @block = prev_block + end + prev_block.ctx.qegg = qegg if prev_block + end + + # queue parameters (Yahns::QueueEgg) + %w(max_events worker_threads).each do |_v| + eval( + %Q(def #{_v}(val);) << + %Q( _check_in_block(:queue, :#{_v});) << + %Q( @block.ctx.__send__("#{_v}=", _check_int(:#{_v}, val, 1));) << + %Q(end) + ) + end + + def _check_int(var, n, min) + Integer === n or raise ArgumentError, "not an integer: #{var}=#{n.inspect}" + n >= min or raise ArgumentError, "too low (< #{min}): #{var}=#{n.inspect}" + n + end + + # global + def client_expire_threshold(val) + var = _check_in_block(nil, :client_expire_threshold) + val > 0 or raise ArgumentError, "#{var} must be > 0" + case val + when Float + val <= 1.0 or raise ArgumentError, "#{var} must be <= 1.0 if a ratio" + when Integer + else + raise ArgumentError, "#{var} must be a float or integer" + end + @set[var] = val + end + + # type = :rack + def app(type, *args, &block) + var = _check_in_block(nil, :app) + file = "yahns/#{type.to_s}" + begin + require file + rescue LoadError => e + raise ArgumentError, "#{type.inspect} is not a supported app type", + e.backtrace + end + klass = APP_CLASS[type] or + raise TypeError, + "#{var}: #{file} did not register #{type} in #{self.class}::APP_CLASS" + + # apps may have multiple configurator contexts + app = @app_instances[klass.instance_key(*args)] = klass.new(*args) + ctx = app.config_context + if block_given? + @block = CfgBlock.new(:app, ctx) + instance_eval(&block) + @block = nil + end + @app_ctx << ctx + end + + def _check_bool(var, val) + return val if [ true, false ].include?(val) + raise ArgumentError, "#{var} must be boolean" + end + + # boolean config directives for app + %w(check_client_connection + output_buffering + persistent_connections).each do |_v| + eval( + %Q(def #{_v}(bool);) << + %Q( _check_in_block(:app, :#{_v});) << + %Q( @block.ctx.__send__("#{_v}=", _check_bool(:#{_v}, bool));) << + %Q(end) + ) + end + + # integer config directives for app + { + # config name, minimum value + client_body_buffer_size: 1, + client_max_body_size: 0, + client_header_buffer_size: 1, + client_max_header_size: 1, + client_timeout: 0, + }.each do |_v,minval| + eval( + %Q(def #{_v}(val);) << + %Q( _check_in_block(:app, :#{_v});) << + %Q( @block.ctx.__send__("#{_v}=", _check_int(:#{_v}, val, #{minval}));) << + %Q(end) + ) + end + + def input_buffering(val) + var = _check_in_block(:app, :input_buffering) + ok = [ :lazy, true, false ] + ok.include?(val) or + raise ArgumentError, "`#{var}' must be one of: #{ok.inspect}" + @block.ctx.__send__("#{var}=", val) + end + + # used to configure rack.errors destination + def errors(val) + var = _check_in_block(:app, :errors) + if String === val + # we've already bound working_directory by the time we get here + val = File.open(File.expand_path(val), "a") + val.binmode + val.sync = true + else + rt = %w(puts write flush).map(&:to_sym) # match Rack::Lint + rt.all? { |m| val.respond_to?(m) } or raise ArgumentError, + "`#{var}' destination must respond to all of: #{rt.inspect}" + end + @block.ctx.__send__("#{var}=", val) + end + + def commit!(server) + # redirect IOs + { stdout_path: $stdout, stderr_path: $stderr }.each do |key, io| + path = @set[key] + if path == :unset && server.daemon_pipe + @set[key] = path = "/dev/null" + end + File.open(path, 'a') { |fp| io.reopen(fp) } if String === path + io.sync = true + end + + [ :logger, :pid, :worker_processes ].each do |var| + val = @set[var] + server.__send__("#{var}=", val) if val != :unset + end + queue(:default) if @qeggs.empty? + @qeggs.each_value { |qegg| qegg.logger ||= server.logger } + @app_ctx.each { |app| app.logger ||= server.logger } + end +end diff --git a/lib/yahns/daemon.rb b/lib/yahns/daemon.rb new file mode 100644 index 0000000..d12ff69 --- /dev/null +++ b/lib/yahns/daemon.rb @@ -0,0 +1,51 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +module Yahns::Daemon # :nodoc: + # We don't do a lot of standard daemonization stuff: + # * umask is whatever was set by the parent process at startup + # and can be set in config.ru and config_file, so making it + # 0000 and potentially exposing sensitive log data can be bad + # policy. + # * don't bother to chdir("/") here since yahns is designed to + # run inside APP_ROOT. Yahns will also re-chdir() to + # the directory it was started in when being re-executed + # to pickup code changes if the original deployment directory + # is a symlink or otherwise got replaced. + def self.daemon(yahns_server) + $stdin.reopen("/dev/null") + + # We only start a new process group if we're not being reexecuted + # and inheriting file descriptors from our parent + unless ENV['YAHNS_FD'] + # grandparent - reads pipe, exits when master is ready + # \_ parent - exits immediately ASAP + # \_ yahns master - writes to pipe when ready + + # We cannot use Yahns::Sigevent (eventfd) here because we need + # to detect EOF on unexpected death, not just read/write + rd, wr = IO.pipe + grandparent = $$ + if fork + wr.close # grandparent does not write + else + rd.close # yahns master does not read + Process.setsid + exit if fork # parent dies now + end + + if grandparent == $$ + # this will block until Server#join runs (or it dies) + master_pid = (rd.readpartial(16) rescue nil).to_i + unless master_pid > 1 + warn "master failed to start, check stderr log for details" + exit!(1) + end + exit 0 + else # yahns master process + yahns_server.daemon_pipe = wr + end + end + # $stderr/$stderr can/will be redirected separately in the Yahns config + end +end diff --git a/lib/yahns/fdmap.rb b/lib/yahns/fdmap.rb new file mode 100644 index 0000000..0272421 --- /dev/null +++ b/lib/yahns/fdmap.rb @@ -0,0 +1,90 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require 'thread' + +# only initialize this after forking, this is highly volatile and won't +# be able to share data across processes at all. +# This is really a singleton + +class Yahns::Fdmap # :nodoc: + def initialize(logger, client_expire_threshold) + @logger = logger + + if Float === client_expire_threshold + client_expire_threshold *= Process.getrlimit(:NOFILE)[0] + elsif client_expire_treshhold < 0 + client_expire_threshold = Process.getrlimit(:NOFILE)[0] - + client_expire_threshold + end + @client_expire_threshold = client_expire_threshold.to_i + + # This is an array because any sane OS will frequently reuse FDs + # to keep this tightly-packed and favor lower FD numbers + # (consider select(2) performance (not that we use select)) + # An (unpacked) Hash (in MRI) uses 5 more words per entry than an Array, + # and we should expect this array to have around 60K elements + @fdmap_ary = [] + @fdmap_mtx = Mutex.new + @last_expire = 0.0 + @count = 0 + end + + # called immediately after accept() + def add(io) + fd = io.fileno + @fdmap_mtx.synchronize do + if (@count += 1) > @client_expire_threshold + __expire_for(io) + else + @fdmap_ary[fd] = io + end + end + end + + # this is only called in Errno::EMFILE/Errno::ENFILE situations + def desperate_expire_for(io, timeout) + @fdmap_mtx.synchronize { __expire_for(io, timeout) } + end + + # called before IO#close + def decr + # don't bother clearing the element in @fdmap_ary, it'll just be + # overwritten when another client connects (soon). We must not touch + # @fdmap_ary[io.fileno] after IO#close on io + @fdmap_mtx.synchronize { @count -= 1 } + end + + def delete(io) # use with rack.hijack (via yahns) + fd = io.fileno + @fdmap_mtx.synchronize do + @fdmap_ary[fd] = nil + @count -= 1 + end + end + + # expire a bunch of idle clients and register the current one + # We should not be calling this too frequently, it is expensive + # This is called while @fdmap_mtx is held + def __expire_for(io, timeout = nil) + nr = 0 + now = Time.now.to_f + (now - @last_expire) >= 1.0 or return # don't expire too frequently + + # @fdmap_ary may be huge, so always expire a bunch at once to + # avoid getting to this method too frequently + @fdmap_ary.each do |c| + c.respond_to?(:yahns_expire) or next + nr += c.yahns_expire(timeout || c.class.client_timeout) + end + + @fdmap_ary[io.fileno] = io + @last_expire = Time.now.to_f + msg = timeout ? "timeout=#{timeout})" : "client_timeout" + @logger.info("dropping #{nr} of #@count clients for #{msg}") + end + + # used for graceful shutdown + def size + @fdmap_mtx.synchronize { @count } + end +end diff --git a/lib/yahns/http_client.rb b/lib/yahns/http_client.rb new file mode 100644 index 0000000..8171460 --- /dev/null +++ b/lib/yahns/http_client.rb @@ -0,0 +1,196 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require 'yahns/tiny_input' +class Yahns::HttpClient < Kgio::Socket # :nodoc: + NULL_IO = Yahns::TinyInput.new("") + + include Yahns::HttpResponse + include Yahns::ClientExpire + QEV_FLAGS = Yahns::Queue::QEV_RD # used by acceptor + HTTP_RESPONSE_START = [ 'HTTP', '/1.1 ' ] + + # A frozen format for this is about 15% faster (note from Mongrel) + REMOTE_ADDR = 'REMOTE_ADDR'.freeze + RACK_INPUT = 'rack.input'.freeze + RACK_HIJACK = 'rack.hijack'.freeze + RACK_HIJACK_IO = "rack.hijack_io".freeze + + # called from acceptor thread + def yahns_init + @hs = Unicorn::HttpRequest.new + @response_start_sent = false + @state = :headers # :body, :trailers, :pipelined, Wbuf, StreamFile + @input = nil + end + + # use if writes are deferred by buffering, this return value goes to + # the main epoll/kqueue worker loop + # returns :wait_readable, :wait_writable, or nil + def step_write + case rv = @state.wbuf_flush(self) + when :wait_writable, :wait_readable + return rv # tell epoll/kqueue to wait on this more + when :delete # :delete on hijack + @state = :delete + return :delete + when Yahns::StreamFile + @state = rv # continue looping + when true, false # done + return http_response_done(rv) + else + raise "BUG: #{@state.inspect}#wbuf_flush returned #{rv.inspect}" + end while true + end + + def mkinput_preread + @state = :body + @input = self.class.tmpio_for(@hs.content_length) + rbuf = Thread.current[:yahns_rbuf] + @hs.filter_body(rbuf, @hs.buf) + @input.write(rbuf) + end + + def input_ready + empty_body = 0 == @hs.content_length + k = self.class + case k.input_buffering + when true + # common case is an empty body + return NULL_IO if empty_body + + # content_length is nil (chunked) or len > 0 + mkinput_preread # keep looping + false + else # :lazy, false + empty_body ? NULL_IO : k.mkinput(self, @hs) + end + end + + # the main entry point of the epoll/kqueue worker loop + def yahns_step + # always write unwritten data first if we have any + return step_write if Yahns::WbufCommon === @state + + # only read if we had nothing to write in this event loop iteration + k = self.class + rbuf = Thread.current[:yahns_rbuf] # running under spawn_worker_threads + + case @state + when :pipelined + if @hs.parse + input = input_ready and return app_call(input) + # @state == :body if we get here point (input_ready -> mkinput_preread) + else + @state = :headers + end + # continue to outer loop + when :headers + case rv = kgio_tryread(k.client_header_buffer_size, rbuf) + when String + if @hs.add_parse(rv) + input = input_ready and return app_call(input) + break # to outer loop to reevaluate @state == :body + end + # keep looping on kgio_tryread + when :wait_readable, :wait_writable, nil + return rv + end while true + when :body + if @hs.body_eof? + if @hs.content_length || @hs.parse # hp.parse == trailers done! + @input.rewind + return app_call(@input) + else # possible Transfer-Encoding:chunked, keep looping + @state = :trailers + end + else + case rv = kgio_tryread(k.client_body_buffer_size, rbuf) + when String + @hs.filter_body(rbuf, @hs.buf << rbuf) + @input.write(rbuf) + # keep looping on kgio_tryread... + when :wait_readable, :wait_writable + return rv # have epoll/kqueue wait for more + when nil # unexpected EOF + return @input.close # nil + end # continue to outer loop (case @state) + end + when :trailers + case rv = kgio_tryread(k.client_header_buffer_size, rbuf) + when String + if @hs.add_parse(rbuf) + @input.rewind + return app_call(@input) + end + # keep looping on kgio_tryread... + when :wait_readable, :wait_writable + return rv # wait for more + when nil # unexpected EOF + return @input.close # nil + end while true + end while true # outer loop + rescue => e + handle_error(e) + end + + def app_call(input) + env = @hs.env + env[REMOTE_ADDR] = @kgio_addr + env[RACK_HIJACK] = hijack_proc(env) + env[RACK_INPUT] = @input ||= input + k = self.class + + if k.check_client_connection && @hs.headers? + @response_start_sent = true + # FIXME: we should buffer this just in case + HTTP_RESPONSE_START.each { |c| kgio_write(c) } + end + + # run the rack app + response = k.app.call(env.merge!(k.app_defaults)) + return :delete if env.include?(RACK_HIJACK_IO) + + # this returns :wait_readable, :wait_writable, :delete, or nil: + http_response_write(*response) + end + + def hijack_proc(env) + proc { env[RACK_HIJACK_IO] = self } + end + + # called automatically by kgio_write + def kgio_wait_writable(timeout = self.class.client_timeout) + super timeout + end + + # called automatically by kgio_read + def kgio_wait_readable(timeout = self.class.client_timeout) + super timeout + end + + # if we get any error, try to write something back to the client + # assuming we haven't closed the socket, but don't get hung up + # if the socket is already closed or broken. We'll always return + # nil to ensure the socket is closed at the end of this function + def handle_error(e) + code = case e + when EOFError,Errno::ECONNRESET,Errno::EPIPE,Errno::ENOTCONN + return # don't send response, drop the connection + when Unicorn::RequestURITooLongError + 414 + when Unicorn::RequestEntityTooLargeError + 413 + when Unicorn::HttpParserError # try to tell the client they're bad + 400 + else + Yahns::Log.exception(@hs.env["rack.logger"], "app error", e) + 500 + end + kgio_trywrite(err_response(code)) + rescue + ensure + shutdown rescue nil + return # always drop the connection on uncaught errors + end +end diff --git a/lib/yahns/http_context.rb b/lib/yahns/http_context.rb new file mode 100644 index 0000000..1af41df --- /dev/null +++ b/lib/yahns/http_context.rb @@ -0,0 +1,66 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require 'yahns/tiny_input' + +# subclasses of Yahns::HttpClient will class extend this + +module Yahns::HttpContext # :nodoc: + attr_accessor :check_client_connection + attr_accessor :client_body_buffer_size + attr_accessor :client_header_buffer_size + attr_accessor :client_max_body_size + attr_accessor :client_max_header_size + attr_accessor :input_buffering # :lazy, true, false + attr_accessor :output_buffering # true, false + attr_accessor :persistent_connections # true or false only + attr_accessor :client_timeout + attr_accessor :qegg + attr_reader :app + attr_reader :app_defaults + + def http_ctx_init(yahns_rack) + @yahns_rack = yahns_rack + @app_defaults = yahns_rack.app_defaults + @check_client_connection = false + @client_body_buffer_size = 112 * 1024 + @client_header_buffer_size = 4000 + @client_max_body_size = 1024 * 1024 + @input_buffering = true + @output_buffering = true + @persistent_connections = true + @client_timeout = 15 + @qegg = nil + end + + # call this after forking + def after_fork_init + @app = @yahns_rack.app_after_fork + end + + # call this immediately after successful accept()/accept4() + def logger=(l) # cold + @logger = @app_defaults["rack.logger"] = l + end + + def logger + @app_defaults["rack.logger"] + end + + def mkinput(client, hs) + (@input_buffering ? Yahns::TeeInput : Yahns::StreamInput).new(client, hs) + end + + def errors=(dest) + @app_defaults["rack.errors"] = dest + end + + def errors + @app_defaults["rack.errors"] + end + + def tmpio_for(len) + len && len <= @client_body_buffer_size ? + Yahns::TinyInput.new("") : Yahns::TmpIO.new + end +end diff --git a/lib/yahns/http_response.rb b/lib/yahns/http_response.rb new file mode 100644 index 0000000..aad2762 --- /dev/null +++ b/lib/yahns/http_response.rb @@ -0,0 +1,183 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative 'stream_file' + +# Writes a Rack response to your client using the HTTP/1.1 specification. +# You use it by simply doing: +# +# status, headers, body = rack_app.call(env) +# http_response_write(status, headers, body) +# +# Most header correctness (including Content-Length and Content-Type) +# is the job of Rack, with the exception of the "Date" header. +module Yahns::HttpResponse # :nodoc: + include Unicorn::HttpResponse + + # avoid GC overhead for frequently used-strings: + CONN_KA = "Connection: keep-alive\r\n\r\n" + CONN_CLOSE = "Connection: close\r\n\r\n" + Z = "" + RESPONSE_START = "HTTP/1.1 " + + def response_start + @response_start_sent ? Z : RESPONSE_START + end + + def response_wait_write(rv) + # call the kgio_wait_readable or kgio_wait_writable method + ok = __send__("kgio_#{rv}") and return ok + k = self.class + k.logger.info("fd=#{fileno} ip=#@kgio_addr timeout on :#{rv} after "\ + "#{k.client_timeout}s") + nil + end + + def err_response(code) + "#{response_start}#{CODES[code]}\r\n\r\n" + end + + def response_header_blocked(ret, header, body, alive, offset, count) + if body.respond_to?(:to_path) + alive = Yahns::StreamFile.new(body, alive, offset, count) + body = nil + end + wbuf = Yahns::Wbuf.new(body, alive) + rv = wbuf.wbuf_write(self, header) + body.each { |chunk| rv = wbuf.wbuf_write(self, chunk) } if body + wbuf_maybe(wbuf, rv, alive) + end + + def wbuf_maybe(wbuf, rv, alive) + case rv # trysendfile return value + when nil + case alive + when :delete + @state = :delete + when true, false + http_response_done(alive) + end + else + @state = wbuf + rv + end + end + + def http_response_done(alive) + @input = @input.discard if @input + if alive + @response_start_sent = false + # @hs.buf will have data if the client pipelined + if @hs.buf.empty? + @state = :headers + :wait_readable + else + @state = :pipelined + # may need to wait for readability if SSL, + # only need writability if plain TCP + :wait_readwrite + end + else + # shutdown is needed in case the app forked, we rescue here since + # StreamInput may issue shutdown as well + shutdown rescue nil + nil # trigger close + end + end + + # writes the rack_response to socket as an HTTP response + # returns :wait_readable, :wait_writable, :forget, or nil + def http_response_write(status, headers, body) + status = CODES[status.to_i] || status + offset = 0 + count = hijack = nil + alive = @hs.next? + + if @hs.headers? + buf = "#{response_start}#{status}\r\nDate: #{httpdate}\r\n" + headers.each do |key, value| + case key + when %r{\ADate\z} + next + when %r{\AContent-Range\z}i + if %r{\Abytes (\d+)-(\d+)/\d+\z} =~ value + offset = $1.to_i + count = $2.to_i - offset + 1 + end + when %r{\AConnection\z}i + # allow Rack apps to tell us they want to drop the client + alive = !!(value =~ /\bclose\b/i) + when "rack.hijack" + hijack = value + body = nil # ensure we do not close body + else + if value =~ /\n/ + # avoiding blank, key-only cookies with /\n+/ + buf << value.split(/\n+/).map! { |v| "#{key}: #{v}\r\n" }.join + else + buf << "#{key}: #{value}\r\n" + end + end + end + buf << (alive ? CONN_KA : CONN_CLOSE) + case rv = kgio_trywrite(buf) + when nil # all done, likely + break + when String + buf = rv # hope the skb grows + when :wait_writable, :wait_readable + if self.class.output_buffering + alive = hijack ? hijack : alive + rv = response_header_blocked(rv, buf, body, alive, offset, count) + body = nil # ensure we do not close body in ensure + return rv + else + response_wait_write(rv) or return + end + end while true + end + + if hijack + hijack.call(self) + return :delete # trigger EPOLL_CTL_DEL + end + + if body.respond_to?(:to_path) + @state = body = Yahns::StreamFile.new(body, alive, offset, count) + return step_write + end + + wbuf = rv = nil + body.each do |chunk| + if wbuf + rv = wbuf.wbuf_write(self, chunk) + else + case rv = kgio_trywrite(chunk) + when nil # all done, likely and good! + break + when String + chunk = rv # hope the skb grows when we loop into the trywrite + when :wait_writable, :wait_readable + if self.class.output_buffering + wbuf = Yahns::Wbuf.new(body, alive) + rv = wbuf.wbuf_write(self, chunk) + break + else + response_wait_write(rv) or return + end + end while true + end + end + + # if we buffered the write body, we must return :wait_writable + # (or :wait_readable for SSL) and hit Yahns::HttpClient#step_write + if wbuf + body = nil # ensure we do not close the body in ensure + wbuf_maybe(wbuf, rv, alive) + else + http_response_done(alive) + end + ensure + body.respond_to?(:close) and body.close + end +end diff --git a/lib/yahns/log.rb b/lib/yahns/log.rb new file mode 100644 index 0000000..59baa85 --- /dev/null +++ b/lib/yahns/log.rb @@ -0,0 +1,73 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) + +# logging-related utility functions for all of yahns +module Yahns::Log # :nodoc: + def self.exception(logger, prefix, exc) + message = exc.message + message = message.dump if /[[:cntrl:]]/ =~ message # prevent code injection + logger.error "#{prefix}: #{message} (#{exc.class})" + exc.backtrace.each { |line| logger.error(line) } + end + + def self.is_log?(fp) + append_flags = IO::WRONLY | IO::APPEND + + ! fp.closed? && + fp.stat.file? && + fp.sync && + (fp.fcntl(Fcntl::F_GETFL) & append_flags) == append_flags + rescue IOError, Errno::EBADF + false + end + + def self.chown_all(uid, gid) + ObjectSpace.each_object(File) do |fp| + fp.chown(uid, gid) if is_log?(fp) + end + end + + # This reopens ALL logfiles in the process that have been rotated + # using logrotate(8) (without copytruncate) or similar tools. + # A +File+ object is considered for reopening if it is: + # 1) opened with the O_APPEND and O_WRONLY flags + # 2) the current open file handle does not match its original open path + # 3) unbuffered (as far as userspace buffering goes, not O_SYNC) + # Returns the number of files reopened + def self.reopen_all + to_reopen = [] + nr = 0 + ObjectSpace.each_object(File) { |fp| is_log?(fp) and to_reopen << fp } + + to_reopen.each do |fp| + orig_st = begin + fp.stat + rescue IOError, Errno::EBADF + next + end + + begin + b = File.stat(fp.path) + next if orig_st.ino == b.ino && orig_st.dev == b.dev + rescue Errno::ENOENT + end + + begin + File.open(fp.path, 'a') { |tmpfp| fp.reopen(tmpfp) } + fp.sync = true + new_st = fp.stat + + # this should only happen in the master: + if orig_st.uid != new_st.uid || orig_st.gid != new_st.gid + fp.chown(orig_st.uid, orig_st.gid) + end + + nr += 1 + rescue IOError, Errno::EBADF + # not much we can do... + end + end + nr + end +end diff --git a/lib/yahns/queue.rb b/lib/yahns/queue.rb new file mode 100644 index 0000000..add4f78 --- /dev/null +++ b/lib/yahns/queue.rb @@ -0,0 +1,7 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +if SleepyPenguin.const_defined?(:Epoll) + require_relative 'queue_epoll' +else + require_relative 'queue_kqueue' # TODO +end diff --git a/lib/yahns/queue_egg.rb b/lib/yahns/queue_egg.rb new file mode 100644 index 0000000..a2abc2f --- /dev/null +++ b/lib/yahns/queue_egg.rb @@ -0,0 +1,23 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) + +# this represents a Yahns::Queue before its vivified. This only +# lives in the parent process and should be clobbered after qc_vivify +class Yahns::QueueEgg # :nodoc: + attr_writer :max_events, :worker_threads + attr_accessor :logger + + def initialize + @max_events = 1 # 1 is good if worker_threads > 1 + @worker_threads = 7 # any default is wrong for most apps... + @logger = nil + end + + # only call after forking + def qc_vivify(fdmap) + queue = Yahns::Queue.new + queue.fdmap = fdmap + queue.spawn_worker_threads(@logger, @worker_threads, @max_events) + queue + end +end diff --git a/lib/yahns/queue_epoll.rb b/lib/yahns/queue_epoll.rb new file mode 100644 index 0000000..c9febc4 --- /dev/null +++ b/lib/yahns/queue_epoll.rb @@ -0,0 +1,57 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +class Yahns::Queue < SleepyPenguin::Epoll::IO # :nodoc: + include SleepyPenguin + attr_accessor :fdmap # Yahns::Fdmap + + # public + QEV_RD = Epoll::IN | Epoll::ONESHOT + QEV_WR = Epoll::OUT | Epoll::ONESHOT + QEV_RDWR = QEV_RD | QEV_WR + + def self.new + super(SleepyPenguin::Epoll::CLOEXEC) + end + + # for HTTP and HTTPS servers, we rely on the io writing to us, first + # flags: QEV_RD/QEV_WR (usually QEV_RD) + def queue_add(io, flags) + @fdmap.add(io) + epoll_ctl(Epoll::CTL_ADD, io, flags) + end + + # returns an array of infinitely running threads + def spawn_worker_threads(logger, worker_threads, max_events) + worker_threads.times do + Thread.new do + Thread.current[:yahns_rbuf] = "" + begin + epoll_wait(max_events) do |_, io| # don't care for flags for now + case rv = io.yahns_step + when :wait_readable + epoll_ctl(Epoll::CTL_MOD, io, QEV_RD) + when :wait_writable + epoll_ctl(Epoll::CTL_MOD, io, QEV_WR) + when :wait_readwrite + epoll_ctl(Epoll::CTL_MOD, io, QEV_RDWR) + when :delete # only used by rack.hijack + epoll_ctl(Epoll::CTL_DEL, io, 0) + @fdmap.delete(io) + when nil + # this is be the ONLY place where we call IO#close on + # things inside the queue + io.close + @fdmap.decr + else + raise "BUG: #{io.inspect}#yahns_step returned: #{rv.inspect}" + end + end + rescue => e + break if (IOError === e || Errno::EBADF === e) && closed? + Yahns::Log.exception(logger, 'queue loop', e) + end while true + end + end + end +end diff --git a/lib/yahns/rack.rb b/lib/yahns/rack.rb new file mode 100644 index 0000000..27857ec --- /dev/null +++ b/lib/yahns/rack.rb @@ -0,0 +1,80 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require 'rack' +class Yahns::Rack # :nodoc: + attr_reader :preload + + # enforce a single instance for the identical config.ru + def self.instance_key(*args) + ru = args[0] + + # it's safe to expand_path now since we enforce working_directory in the + # top-level config is called before any apps are created + # ru may also be a Rack::Builder object or any already-built Rack app + ru.respond_to?(:call) ? ru.object_id : File.expand_path(ru) + end + + def initialize(ru, opts = {}) + # always called after config file parsing, may be called after forking + @app = lambda do + ENV["RACK_ENV"] ||= "none" + if ru.respond_to?(:call) + inner_app = ru.respond_to?(:to_app) ? ru.to_app : ru + else + inner_app = case ru + when /\.ru$/ + raw = File.read(ru) + raw.sub!(/^__END__\n.*/, '') + eval("Rack::Builder.new {(\n#{raw}\n)}.to_app", TOPLEVEL_BINDING, ru) + else + require ru + Object.const_get(File.basename(ru, '.rb').capitalize) + end + end + inner_app + end + @ru = ru + @preload = opts[:preload] + build_app! if @preload + end + + def config_context + ctx_class = Class.new(Yahns::HttpClient) + ctx_class.extend(Yahns::HttpContext) + ctx_class.http_ctx_init(self) + ctx_class + end + + def build_app! + if @app.respond_to?(:arity) && @app.arity == 0 + Gem.refresh if defined?(Gem) && Gem.respond_to?(:refresh) + @app = @app.call + end + end + + # allow different HttpContext instances to have different Rack defaults + def app_defaults + { + # logger is set in http_context + "rack.errors" => $stderr, + "rack.multiprocess" => true, + "rack.multithread" => true, + "rack.run_once" => false, + "rack.hijack?" => true, + "rack.version" => [ 1, 2 ], + "SCRIPT_NAME" => "", + + # this is not in the Rack spec, but some apps may rely on it + "SERVER_SOFTWARE" => "yahns" + } + end + + def app_after_fork + build_app! unless @preload + @app + end +end + +# register ourselves +Yahns::Config::APP_CLASS[:rack] = Yahns::Rack diff --git a/lib/yahns/server.rb b/lib/yahns/server.rb new file mode 100644 index 0000000..c7a5a57 --- /dev/null +++ b/lib/yahns/server.rb @@ -0,0 +1,328 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +class Yahns::Server # :nodoc: + QUEUE_SIGS = [ :WINCH, :QUIT, :INT, :TERM, :USR1, :USR2, :HUP, :TTIN, :TTOU ] + attr_accessor :daemon_pipe + attr_accessor :logger + attr_writer :worker_processes + include Yahns::SocketHelper + + def initialize(config) + @reexec_pid = 0 + @daemon_pipe = nil # writable IO or true + @config = config + @workers = {} # pid -> workers + @sig_queue = [] # nil in forked workers + @logger = Logger.new($stderr) + @sev = Yahns::Sigevent.new + @listeners = [] + @pid = nil + @worker_processes = nil + @user = nil + end + + def sqwakeup(sig) + @sig_queue << sig + @sev.sev_signal + end + + def start + @config.commit!(self) + inherit_listeners! + # we try inheriting listeners first, so we bind them later. + # we don't write the pid file until we've bound listeners in case + # yahns was started twice by mistake. Even though our #pid= method + # checks for stale/existing pid files, race conditions are still + # possible (and difficult/non-portable to avoid) and can be likely + # to clobber the pid if the second start was in quick succession + # after the first, so we rely on the listener binding to fail in + # that case. Some tests (in and outside of this source tree) and + # monitoring tools may also rely on pid files existing before we + # attempt to connect to the listener(s) + + # setup signal handlers before writing pid file in case people get + # trigger happy and send signals as soon as the pid file exists. + QUEUE_SIGS.each { |sig| trap(sig) { sqwakeup(sig) } } + self.pid = @config.value(:pid) # write pid file + bind_new_listeners! + if @worker_processes + require 'yahns/server_mp' + extend Yahns::ServerMP + mp_init + end + self + end + + # replaces current listener set with +listeners+. This will + # close the socket if it will not exist in the new listener set + def listeners=(listeners) + cur_names, dead_names = [], [] + listener_names.each do |name| + if ?/ == name[0] + # mark unlinked sockets as dead so we can rebind them + (File.socket?(name) ? cur_names : dead_names) << name + else + cur_names << name + end + end + set_names = listener_names(listeners) + dead_names.concat(cur_names - set_names).uniq! + + @listeners.delete_if do |io| + if dead_names.include?(sock_name(io)) + (io.close rescue nil).nil? # true + else + set_server_sockopt(io, sock_opts(io)) + false + end + end + + (set_names - cur_names).each { |addr| listen(addr) } + end + + # sets the path for the PID file of the master process + def pid=(path) + if path + if x = valid_pid?(path) + return path if @pid && path == @pid && x == $$ + if x == @reexec_pid && @pid =~ /\.oldbin\z/ + @logger.warn("will not set pid=#{path} while reexec-ed "\ + "child is running PID:#{x}") + return + end + raise ArgumentError, "Already running on PID:#{x} " \ + "(or pid=#{path} is stale)" + end + end + unlink_pid_safe(@pid) if @pid + + if path + fp = begin + tmp = "#{File.dirname(path)}/#{rand}.#$$" + File.open(tmp, File::RDWR|File::CREAT|File::EXCL, 0644) + rescue Errno::EEXIST + retry + end + fp.syswrite("#$$\n") + File.rename(fp.path, path) + fp.close + end + @pid = path + end + + # add a given address to the +listeners+ set, idempotently + # Allows workers to add a private, per-process listener via the + # after_fork hook. Very useful for debugging and testing. + # +:tries+ may be specified as an option for the number of times + # to retry, and +:delay+ may be specified as the time in seconds + # to delay between retries. + # A negative value for +:tries+ indicates the listen will be + # retried indefinitely, this is useful when workers belonging to + # different masters are spawned during a transparent upgrade. + def listen(address) + address = @config.expand_addr(address) + return if String === address && listener_names.include?(address) + + begin + io = bind_listen(address, sock_opts(address)) + unless Kgio::TCPServer === io || Kgio::UNIXServer === io + io = server_cast(io) + end + @logger.info "listening on addr=#{sock_name(io)} fd=#{io.fileno}" + @listeners << io + io + rescue Errno::EADDRINUSE => err + @logger.error "adding listener failed addr=#{address} (in use)" + rescue => err + @logger.fatal "error adding listener addr=#{address}" + raise err + end + end + + def daemon_ready + @daemon_pipe or return + @daemon_pipe.syswrite("#$$") + @daemon_pipe.close + @daemon_pipe = true # for SIGWINCH + end + + # reexecutes the Yahns::START with a new binary + def reexec + if @reexec_pid > 0 + begin + Process.kill(0, @reexec_pid) + @logger.error "reexec-ed child already running PID:#@reexec_pid" + return + rescue Errno::ESRCH + @reexec_pid = 0 + end + end + + if @pid + old_pid = "#@pid.oldbin" + begin + self.pid = old_pid # clear the path for a new pid file + rescue ArgumentError + @logger.error "old PID:#{valid_pid?(old_pid)} running with " \ + "existing pid=#{old_pid}, refusing rexec" + return + rescue => e + @logger.error "error writing pid=#{old_pid} #{e.class} #{e.message}" + return + end + end + + @reexec_pid = fork do + redirects = {} + listeners.each do |sock| + sock.close_on_exec = false + redirects[sock.fileno] = sock + end + ENV['YAHNS_FD'] = redirects.keys.map(&:to_s).join(',') + Dir.chdir(@config.value(:working_directory) || Yahns::START[:cwd]) + cmd = [ Yahns::START[0] ].concat(Yahns::START[:argv]) + @logger.info "executing #{cmd.inspect} (in #{Dir.pwd})" + cmd << redirects + exec(*cmd) + end + proc_name 'master (old)' + end + + # unlinks a PID file at given +path+ if it contains the current PID + # still potentially racy without locking the directory (which is + # non-portable and may interact badly with other programs), but the + # window for hitting the race condition is small + def unlink_pid_safe(path) + (File.read(path).to_i == $$ and File.unlink(path)) rescue nil + end + + # returns a PID if a given path contains a non-stale PID file, + # nil otherwise. + def valid_pid?(path) + wpid = File.read(path).to_i + wpid <= 0 and return + Process.kill(0, wpid) + wpid + rescue Errno::EPERM + @logger.info "pid=#{path} possibly stale, got EPERM signalling PID:#{wpid}" + nil + rescue Errno::ESRCH, Errno::ENOENT + # don't unlink stale pid files, racy without non-portable locking... + end + + def load_config! + @logger.info "reloading config_file=#{@config.config_file}" + @config.config_reload! + @config.commit!(self) + kill_each_worker(:QUIT) + Yahns::Log.reopen_all + @logger.info "done reloading config_file=#{@config.config_file}" + rescue StandardError, LoadError, SyntaxError => e + Yahns::Log.exception(@logger, + "error reloading config_file=#{@config.config_file}", e) + end + + # returns an array of string names for the given listener array + def listener_names(listeners = @listeners) + listeners.map { |io| sock_name(io) } + end + + def sock_opts(io) + @config.config_listeners[sock_name(io)] + end + + def inherit_listeners! + # inherit sockets from parents, they need to be plain Socket objects + # before they become Kgio::UNIXServer or Kgio::TCPServer + inherited = ENV['YAHNS_FD'].to_s.split(/,/).map do |fd| + io = Socket.for_fd(fd.to_i) + set_server_sockopt(io, sock_opts(io)) + @logger.info "inherited addr=#{sock_name(io)} fd=#{fd}" + server_cast(io) + end + + @listeners.replace(inherited) + end + + # call only after calling inherit_listeners! + # This binds any listeners we did NOT inherit from the parent + def bind_new_listeners! + self.listeners = @config.config_listeners.keys + raise ArgumentError, "no listeners" if @listeners.empty? + @listeners.each { |l| l.extend(Yahns::Acceptor) } + end + + def proc_name(tag) + s = Yahns::START + $0 = ([ File.basename(s[0]), tag ]).concat(s[:argv]).join(' ') + end + + # spins up processing threads of the server + def fdmap_init + thresh = @config.value(:client_expire_threshold) + + # keeps track of all connections, like ObjectSpace, but only for IOs + fdmap = Yahns::Fdmap.new(@logger, thresh) + + # initialize queues (epoll/kqueue) and associated worker threads + queues = {} + @config.qeggs.each do |name, qegg| + queue = qegg.qc_vivify(fdmap) # worker threads run after this + queues[qegg] = queue + end + + # spin up applications (which are preload: false) + @config.app_ctx.each { |ctx| ctx.after_fork_init } + + # spin up acceptors, clients flow into worker queues after this + @listeners.each do |l| + ctx = sock_opts(l)[:yahns_app_ctx] + qegg = ctx.qegg || @config.qeggs[:default] + + # acceptors feed the the queues + l.spawn_acceptor(@logger, ctx, queues[qegg]) + end + fdmap + end + + def usr1_reopen(prefix) + @logger.info "#{prefix}reopening logs..." + Yahns::Log.reopen_all + @logger.info "#{prefix}done reopening logs" + end + + def sp_sig_handle(alive) + @sev.kgio_wait_readable(alive ? nil : 0.01) + @sev.yahns_step + case sig = @sig_queue.shift + when :QUIT, :TERM, :INT + self.listeners = [] # stop accepting new connections + exit(0) unless alive + return false + when :USR1 + usr1_reopen('') + when :USR2 + reexec + when :HUP + reexec + return false + when :TTIN, :TTOU, :WINCH + @logger.info("SIG#{sig} ignored in single-process mode") + end + alive + end + + # single-threaded only, this is overriden if @worker_processes is non-nil + def join + daemon_ready + fdmap = fdmap_init + alive = true + begin + alive = sp_sig_handle(alive) + rescue => e + Yahns::Log.exception(@logger, "main loop", e) + end while alive || fdmap.size > 0 + unlink_pid_safe(@pid) if @pid + end +end diff --git a/lib/yahns/server_mp.rb b/lib/yahns/server_mp.rb new file mode 100644 index 0000000..8818bac --- /dev/null +++ b/lib/yahns/server_mp.rb @@ -0,0 +1,184 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +module Yahns::ServerMP # :nodoc: + EXIT_SIGS = [ :QUIT, :TERM, :INT ] + + def mp_init + trap(:CHLD) { @sev.sev_signal } + end + + # reaps all unreaped workers + def reap_all_workers + begin + wpid, status = Process.waitpid2(-1, Process::WNOHANG) + wpid or return + if @reexec_pid == wpid + @logger.error "reaped #{status.inspect} exec()-ed" + @reexec_pid = 0 + self.pid = @pid.chomp('.oldbin') if @pid + proc_name 'master' + else + worker = @workers.delete(wpid) + worker_id = worker ? worker.nr : "(unknown)" + m = "reaped #{status.inspect} worker=#{worker_id}" + status.success? ? @logger.info(m) : @logger.error(m) + end + rescue Errno::ECHILD + return + end while true + end + + def maintain_worker_count + (off = @workers.size - @worker_processes) == 0 and return + off < 0 and return spawn_missing_workers + @workers.each_pair do |wpid, worker| + worker.nr >= @worker_processes and Process.kill(:QUIT, wpid) + end + end + + # delivers a signal to each worker + def kill_each_worker(signal) + @workers.each_key { |wpid| Process.kill(signal, wpid) } + end + + # this is the first thing that runs after forking in a child + # gets rid of stuff the worker has no business keeping track of + # to free some resources and drops all sig handlers. + # traps for USR1, USR2, and HUP may be set in the after_fork Proc + # by the user. + def after_fork_internal(worker) + worker.atfork_child + + # daemon_pipe may be true for non-initial workers + @daemon_pipe = @daemon_pipe.close if @daemon_pipe.respond_to?(:close) + + srand # in case this pops up again: https://bugs.ruby-lang.org/issues/4338 + + # The OpenSSL PRNG is seeded with only the pid, and apps with frequently + # dying workers can recycle pids + OpenSSL::Random.seed(rand.to_s) if defined?(OpenSSL::Random) + # we'll re-trap EXIT_SIGS later for graceful shutdown iff we accept clients + EXIT_SIGS.each { |sig| trap(sig) { exit!(0) } } + exit!(0) if (@sig_queue & EXIT_SIGS)[0] # did we inherit sigs from parent? + @sig_queue = [] + + # ignore WINCH, TTIN, TTOU, HUP in the workers + (Yahns::Server::QUEUE_SIGS - EXIT_SIGS).each { |sig| trap(sig, nil) } + trap(:CHLD, 'DEFAULT') + @logger.info("worker=#{worker.nr} spawned pid=#$$") + proc_name "worker[#{worker.nr}]" + Yahns::START.clear + @sev.close + @sev = Yahns::Sigevent.new + worker.user(*@user) if @user + @user = @workers = nil + end + + def spawn_missing_workers + worker_nr = -1 + until (worker_nr += 1) == @worker_processes + @workers.value?(worker_nr) and next + worker = Yahns::Worker.new(worker_nr) + @logger.info("worker=#{worker_nr} spawning...") + if pid = fork + @workers[pid] = worker.atfork_parent + else + after_fork_internal(worker) + run_mp_worker(worker) + end + end + rescue => e + Yahns::Log.exception(@logger, "spawning worker", e) + exit! + end + + # monitors children and receives signals forever + # (or until a termination signal is sent). This handles signals + # one-at-a-time time and we'll happily drop signals in case somebody + # is signalling us too often. + def join + spawn_missing_workers + state = :respawn # :QUIT, :WINCH + proc_name 'master' + @logger.info "master process ready" + daemon_ready + begin + @sev.kgio_wait_readable + @sev.yahns_step + reap_all_workers + case @sig_queue.shift + when *EXIT_SIGS # graceful shutdown (twice for non graceful) + self.listeners = [] + kill_each_worker(:QUIT) + state = :QUIT + when :USR1 # rotate logs + usr1_reopen("master ") + kill_each_worker(:USR1) + when :USR2 # exec binary, stay alive in case something went wrong + reexec + when :WINCH + if @daemon_pipe + state = :WINCH + @logger.info "gracefully stopping all workers" + kill_each_worker(:QUIT) + @worker_processes = 0 + else + @logger.info "SIGWINCH ignored because we're not daemonized" + end + when :TTIN + state = :respawn unless state == :QUIT + @worker_processes += 1 + when :TTOU + @worker_processes -= 1 if @worker_processes > 0 + when :HUP + state = :respawn unless state == :QUIT + if @config.config_file + load_config! + else # exec binary and exit if there's no config file + @logger.info "config_file not present, reexecuting binary" + reexec + end + end while @sig_queue[0] + maintain_worker_count if state == :respawn + rescue => e + Yahns::Log.exception(@logger, "master loop error", e) + end while state != :QUIT || @workers.size > 0 + @logger.info "master complete" + unlink_pid_safe(@pid) if @pid + end + + def fdmap_init_mp + fdmap = fdmap_init # builds apps (if not preloading) + EXIT_SIGS.each { |sig| trap(sig) { sqwakeup(sig) } } + @config = nil + fdmap + end + + def run_mp_worker(worker) + fdmap = fdmap_init_mp + alive = true + begin + alive = mp_sig_handle(worker, alive) + rescue => e + Yahns::Log.exception(@logger, "main worker loop", e) + end while alive || fdmap.size > 0 + exit + end + + def mp_sig_handle(worker, alive) + # not performance critical + r = IO.select([worker, @sev], nil, nil, alive ? nil : 0.01) and + r[0].each { |io| io.yahns_step } + case sig = @sig_queue.shift + when *EXIT_SIGS + self.listeners = [] + exit(0) unless alive # drop connections immediately if signaled twice + @logger.info("received SIG#{sig}, gracefully exiting") + return false + when :USR1 + usr1_reopen("worker ") + end + alive + end +end diff --git a/lib/yahns/sigevent.rb b/lib/yahns/sigevent.rb new file mode 100644 index 0000000..aa95f4b --- /dev/null +++ b/lib/yahns/sigevent.rb @@ -0,0 +1,7 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +if SleepyPenguin.const_defined?(:EventFD) + require_relative 'sigevent_efd' +else + require_relative 'sigevent_pipe' +end diff --git a/lib/yahns/sigevent_efd.rb b/lib/yahns/sigevent_efd.rb new file mode 100644 index 0000000..8f10ad6 --- /dev/null +++ b/lib/yahns/sigevent_efd.rb @@ -0,0 +1,18 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +class Yahns::Sigevent < SleepyPenguin::EventFD # :nodoc: + include Kgio::DefaultWaiters + def self.new + super(0, SleepyPenguin::EventFD::CLOEXEC) + end + + def sev_signal + incr(1) # eventfd_write + end + + def yahns_step + value(true) # eventfd_read, we ignore this data + :wait_readable + end +end diff --git a/lib/yahns/sigevent_pipe.rb b/lib/yahns/sigevent_pipe.rb new file mode 100644 index 0000000..6e1be53 --- /dev/null +++ b/lib/yahns/sigevent_pipe.rb @@ -0,0 +1,29 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +class Yahns::Sigevent # :nodoc: + attr_reader :to_io + def initialize + @to_io, @wr = Kgio::Pipe.new + end + + def kgio_wait_readable + @to_io.kgio_wait_readable + end + + def sev_signal + @wr.kgio_write(".") + end + + def yahns_step + # 11 byte strings -> no malloc on YARV + while String === @to_io.kgio_tryread(11) + end + :wait_readable + end + + def close + @to_io.close + @wr.close + end +end diff --git a/lib/yahns/socket_helper.rb b/lib/yahns/socket_helper.rb new file mode 100644 index 0000000..61f2b0f --- /dev/null +++ b/lib/yahns/socket_helper.rb @@ -0,0 +1,117 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +# this is only meant for Yahns::Server +module Yahns::SocketHelper # :nodoc: + def set_server_sockopt(sock, opt) + opt = {backlog: 1024}.merge!(opt) if opt + + TCPSocket === sock and sock.setsockopt(:IPPROTO_TCP, :TCP_NODELAY, 1) + sock.setsockopt(:SOL_SOCKET, :SO_KEEPALIVE, 1) + + if opt[:rcvbuf] || opt[:sndbuf] + log_buffer_sizes(sock, "before: ") + { SO_RCVBUF: :rcvbuf, SO_SNDBUF: :sndbuf }.each do |optname,cfgname| + val = opt[cfgname] and sock.setsockopt(:SOL_SOCKET, optname, val) + end + log_buffer_sizes(sock, " after: ") + end + sock.listen(opt[:backlog]) + rescue => e + Yahns::Log.exception(@logger, "#{sock_name(sock)} #{opt.inspect}", e) + end + + def log_buffer_sizes(sock, pfx = '') + rcvbuf = sock.getsockopt(:SOL_SOCKET, :SO_RCVBUF).int + sndbuf = sock.getsockopt(:SOL_SOCKET, :SO_SNDBUF).int + @logger.info("#{pfx}#{sock_name(sock)} rcvbuf=#{rcvbuf} sndbuf=#{sndbuf}") + end + + # creates a new server, socket. address may be a HOST:PORT or + # an absolute path to a UNIX socket. address can even be a Socket + # object in which case it is immediately returned + def bind_listen(address, opt) + return address unless String === address + opt ||= {} + + sock = if address[0] == ?/ + if File.exist?(address) + if File.socket?(address) + begin + UNIXSocket.new(address).close + # fall through, try to bind(2) and fail with EADDRINUSE + # (or succeed from a small race condition we can't sanely avoid). + rescue Errno::ECONNREFUSED + @logger.info "unlinking existing socket=#{address}" + File.unlink(address) + end + else + raise ArgumentError, + "socket=#{address} specified but it is not a socket!" + end + end + old_umask = File.umask(opt[:umask] || 0) + begin + Kgio::UNIXServer.new(address) + ensure + File.umask(old_umask) + end + elsif /\A\[([a-fA-F0-9:]+)\]:(\d+)\z/ =~ address + new_ipv6_server($1, $2.to_i, opt) + elsif /\A(\d+\.\d+\.\d+\.\d+):(\d+)\z/ =~ address + Kgio::TCPServer.new($1, $2.to_i) + else + raise ArgumentError, "Don't know how to bind: #{address}" + end + set_server_sockopt(sock, opt) + sock + end + + def new_ipv6_server(addr, port, opt) + opt.key?(:ipv6only) or return Kgio::TCPServer.new(addr, port) + sock = Socket.new(:AF_INET6, :SOCK_STREAM, 0) + sock.setsockopt(:IPPROTO_IPV6, :IPV6_V6ONLY, opt[:ipv6only] ? 1 : 0) + sock.setsockopt(:SOL_SOCKET, :SO_REUSEADDR, 1) + sock.bind(Socket.pack_sockaddr_in(port, addr)) + sock.autoclose = false + Kgio::TCPServer.for_fd(sock.fileno) + end + + # returns rfc2732-style (e.g. "[::1]:666") addresses for IPv6 + def tcp_name(sock) + port, addr = Socket.unpack_sockaddr_in(sock.getsockname) + /:/ =~ addr ? "[#{addr}]:#{port}" : "#{addr}:#{port}" + end + + # Returns the configuration name of a socket as a string. sock may + # be a string value, in which case it is returned as-is + # Warning: TCP sockets may not always return the name given to it. + def sock_name(sock) + case sock + when String then sock + when UNIXServer + Socket.unpack_sockaddr_un(sock.getsockname) + when TCPServer + tcp_name(sock) + when Socket + begin + tcp_name(sock) + rescue ArgumentError + Socket.unpack_sockaddr_un(sock.getsockname) + end + else + raise ArgumentError, "Unhandled class #{sock.class}: #{sock.inspect}" + end + end + + # casts a given Socket to be a TCPServer or UNIXServer + def server_cast(sock) + sock.autoclose = false + begin + Socket.unpack_sockaddr_in(sock.getsockname) + Kgio::TCPServer.for_fd(sock.fileno) + rescue ArgumentError + Kgio::UNIXServer.for_fd(sock.fileno) + end + end +end diff --git a/lib/yahns/stream_file.rb b/lib/yahns/stream_file.rb new file mode 100644 index 0000000..eba9632 --- /dev/null +++ b/lib/yahns/stream_file.rb @@ -0,0 +1,34 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> et. al. +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative 'wbuf_common' + +class Yahns::StreamFile # :nodoc: + include Yahns::WbufCommon + + def initialize(body, persist, offset, count) + if body.respond_to?(:to_io) + @tmpio = body.to_io + else + path = body.to_path + if path =~ %r{\A/dev/fd/(\d+)\z} + @tmpio = IO.for_fd($1.to_i) + @tmpio.autoclose = false + else + @tmpio = File.open(path) + end + end + @sf_offset = offset + @sf_count = count || @tmpio.stat.size + @wbuf_persist = persist # whether or not we keep the connection alive + @body = body + end + + # called by last wbuf_flush + def wbuf_close(client) + if File === @tmpio && @tmpio != @body + @tmpio.close + end + wbuf_close_common(client) + end +end diff --git a/lib/yahns/stream_input.rb b/lib/yahns/stream_input.rb new file mode 100644 index 0000000..f0a43b3 --- /dev/null +++ b/lib/yahns/stream_input.rb @@ -0,0 +1,150 @@ +# -*- encoding: binary -*- +# Copyright (C) 2009-2013, Eric Wong <normalperson@yhbt.net> et. al. +# License: GPLv2 or later (https://www.gnu.org/licenses/gpl-2.0.txt) + +# When processing uploads, Yahns may expose a StreamInput object under +# "rack.input" of the (future) Rack (2.x) environment. +class Yahns::StreamInput # :nodoc: + # Initializes a new StreamInput object. You normally do not have to call + # this unless you are writing an HTTP server. + def initialize(client, request) + @chunked = request.content_length.nil? + @client = client + @parser = request + @buf = request.buf + @rbuf = '' + @bytes_read = 0 + filter_body(@rbuf, @buf) unless @buf.empty? + end + + # :call-seq: + # ios.read([length [, buffer ]]) => string, buffer, or nil + # + # Reads at most length bytes from the I/O stream, or to the end of + # file if length is omitted or is nil. length must be a non-negative + # integer or nil. If the optional buffer argument is present, it + # must reference a String, which will receive the data. + # + # At end of file, it returns nil or '' depend on length. + # ios.read() and ios.read(nil) returns ''. + # ios.read(length [, buffer]) returns nil. + # + # If the Content-Length of the HTTP request is known (as is the common + # case for POST requests), then ios.read(length [, buffer]) will block + # until the specified length is read (or it is the last chunk). + # Otherwise, for uncommon "Transfer-Encoding: chunked" requests, + # ios.read(length [, buffer]) will return immediately if there is + # any data and only block when nothing is available (providing + # IO#readpartial semantics). + def read(length = nil, rv = '') + if length + if length <= @rbuf.size + length < 0 and raise ArgumentError, "negative length #{length} given" + rv.replace(@rbuf.slice!(0, length)) + else + to_read = length - @rbuf.size + rv.replace(@rbuf.slice!(0, @rbuf.size)) + until to_read == 0 || eof? || (rv.size > 0 && @chunked) + @client.kgio_read(to_read, @buf) or eof! + filter_body(@rbuf, @buf) + rv << @rbuf + to_read -= @rbuf.size + end + @rbuf.replace('') + end + rv = nil if rv.empty? && length != 0 + else + read_all(rv) + end + rv + end + + def __rsize + @client.class.client_body_buffer_size + end + + # :call-seq: + # ios.gets => string or nil + # + # Reads the next ``line'' from the I/O stream; lines are separated + # by the global record separator ($/, typically "\n"). A global + # record separator of nil reads the entire unread contents of ios. + # Returns nil if called at the end of file. + # This takes zero arguments for strict Rack::Lint compatibility, + # unlike IO#gets. + def gets + sep = $/ + if sep.nil? + read_all(rv = '') + return rv.empty? ? nil : rv + end + re = /\A(.*?#{Regexp.escape(sep)})/ + rsize = __rsize + begin + @rbuf.sub!(re, '') and return $1 + return @rbuf.empty? ? nil : @rbuf.slice!(0, @rbuf.size) if eof? + @client.kgio_read(rsize, @buf) or eof! + filter_body(once = '', @buf) + @rbuf << once + end while true + end + + # :call-seq: + # ios.each { |line| block } => ios + # + # Executes the block for every ``line'' in *ios*, where lines are + # separated by the global record separator ($/, typically "\n"). + def each + while line = gets + yield line + end + + self # Rack does not specify what the return value is here + end + + def eof? + if @parser.body_eof? + rsize = __rsize + while @chunked && ! @parser.parse + once = @client.kgio_read(rsize) or eof! + @buf << once + end + @client = nil + true + else + false + end + end + + def filter_body(dst, src) + rv = @parser.filter_body(dst, src) + @bytes_read += dst.size + rv + end + + def read_all(dst) + dst.replace(@rbuf) + @client or return + rsize = @client.class.client_body_buffer_size + until eof? + @client.kgio_read(rsize, @buf) or eof! + filter_body(@rbuf, @buf) + dst << @rbuf + end + ensure + @rbuf.replace('') + end + + def eof! + # in case client only did a premature shutdown(SHUT_WR) + # we do support clients that shutdown(SHUT_WR) after the + # _entire_ request has been sent, and those will not have + # raised EOFError on us. + @client.shutdown if @client + ensure + raise Yahns::ClientShutdown, "bytes_read=#{@bytes_read}", [] + end + + def discard # return nil + end +end diff --git a/lib/yahns/tee_input.rb b/lib/yahns/tee_input.rb new file mode 100644 index 0000000..0d91a89 --- /dev/null +++ b/lib/yahns/tee_input.rb @@ -0,0 +1,114 @@ +# -*- encoding: binary -*- +# Copyright (C) 2009-2013, Eric Wong <normalperson@yhbt.net> et. al. +# License: GPLv2 or later (https://www.gnu.org/licenses/gpl-2.0.txt) + +# acts like tee(1) on an input input to provide a input-like stream +# while providing rewindable semantics through a File/StringIO backing +# store. On the first pass, the input is only read on demand so your +# Rack application can use input notification (upload progress and +# like). This should fully conform to the Rack::Lint::InputWrapper +# specification on the public API. This class is intended to be a +# strict interpretation of Rack::Lint::InputWrapper functionality and +# will not support any deviations from it. +# +# When processing uploads, Yahns exposes a TeeInput object under +# "rack.input" of the Rack environment. +class Yahns::TeeInput < Yahns::StreamInput # :nodoc: + # Initializes a new TeeInput object. You normally do not have to call + # this unless you are writing an HTTP server. + def initialize(client, request) + @len = request.content_length + super + @tmp = client.class.tmpio_for(@len) + end + + # :call-seq: + # ios.size => Integer + # + # Returns the size of the input. For requests with a Content-Length + # header value, this will not read data off the socket and just return + # the value of the Content-Length header as an Integer. + # + # For Transfer-Encoding:chunked requests, this requires consuming + # all of the input stream before returning since there's no other + # way to determine the size of the request body beforehand. + # + # This method is no longer part of the Rack specification as of + # Rack 1.2, so its use is not recommended. This method only exists + # for compatibility with Rack applications designed for Rack 1.1 and + # earlier. Most applications should only need to call +read+ with a + # specified +length+ in a loop until it returns +nil+. + def size + @len and return @len + pos = @tmp.pos + consume! + @tmp.pos = pos + @len = @tmp.size + end + + # :call-seq: + # ios.read([length [, buffer ]]) => string, buffer, or nil + # + # Reads at most length bytes from the I/O stream, or to the end of + # file if length is omitted or is nil. length must be a non-negative + # integer or nil. If the optional buffer argument is present, it + # must reference a String, which will receive the data. + # + # At end of file, it returns nil or "" depend on length. + # ios.read() and ios.read(nil) returns "". + # ios.read(length [, buffer]) returns nil. + # + # If the Content-Length of the HTTP request is known (as is the common + # case for POST requests), then ios.read(length [, buffer]) will block + # until the specified length is read (or it is the last chunk). + # Otherwise, for uncommon "Transfer-Encoding: chunked" requests, + # ios.read(length [, buffer]) will return immediately if there is + # any data and only block when nothing is available (providing + # IO#readpartial semantics). + def read(*args) + @client ? tee(super) : @tmp.read(*args) + end + + # :call-seq: + # ios.gets => string or nil + # + # Reads the next ``line'' from the I/O stream; lines are separated + # by the global record separator ($/, typically "\n"). A global + # record separator of nil reads the entire unread contents of ios. + # Returns nil if called at the end of file. + # This takes zero arguments for strict Rack::Lint compatibility, + # unlike IO#gets. + def gets + @client ? tee(super) : @tmp.gets + end + + # :call-seq: + # ios.rewind => 0 + # + # Positions the *ios* pointer to the beginning of input, returns + # the offset (zero) of the +ios+ pointer. Subsequent reads will + # start from the beginning of the previously-buffered input. + def rewind + return 0 if 0 == @tmp.size + consume! if @client + @tmp.rewind # Rack does not specify what the return value is here + end + + # consumes the stream of the socket + def consume! + junk = "" + rsize = __rsize + nil while read(rsize, junk) + end + + def tee(buffer) + if buffer && buffer.size > 0 + @tmp.write(buffer) + end + buffer + end + + def discard + @tmp = @tmp.close + end +end diff --git a/lib/yahns/tiny_input.rb b/lib/yahns/tiny_input.rb new file mode 100644 index 0000000..55bdd03 --- /dev/null +++ b/lib/yahns/tiny_input.rb @@ -0,0 +1,7 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> et. al. +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +class Yahns::TinyInput < StringIO # :nodoc: + def discard # just returns nil + end +end diff --git a/lib/yahns/tmpio.rb b/lib/yahns/tmpio.rb new file mode 100644 index 0000000..60751c0 --- /dev/null +++ b/lib/yahns/tmpio.rb @@ -0,0 +1,27 @@ +# -*- encoding: binary -*- +# Copyright (C) 2009-2013, Eric Wong <normalperson@yhbt.net> et. al. +# License: GPLv2 or later (https://www.gnu.org/licenses/gpl-2.0.txt) +require 'tmpdir' + +# some versions of Ruby had a broken Tempfile which didn't work +# well with unlinked files. This one is much shorter, easier +# to understand, and slightly faster (no delegation). +class Yahns::TmpIO < File # :nodoc: + + # creates and returns a new File object. The File is unlinked + # immediately, switched to binary mode, and userspace output + # buffering is disabled + def self.new + fp = begin + super("#{Dir.tmpdir}/#{rand}", RDWR|CREAT|EXCL, 0600) + rescue Errno::EEXIST + retry + end + unlink(fp.path) + fp.binmode + fp.sync = true + fp + end + + alias discard close +end diff --git a/lib/yahns/wbuf.rb b/lib/yahns/wbuf.rb new file mode 100644 index 0000000..4828056 --- /dev/null +++ b/lib/yahns/wbuf.rb @@ -0,0 +1,36 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> et. al. +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative 'wbuf_common' + +class Yahns::Wbuf # :nodoc: + include Yahns::WbufCommon + + def initialize(body, persist) + @tmpio = Yahns::TmpIO.new + @sf_offset = @sf_count = 0 + @wbuf_persist = persist # whether or not we keep the connection alive + @body = body + end + + def wbuf_write(client, buf) + @sf_count += @tmpio.write(buf) + case rv = client.trysendfile(@tmpio, @sf_offset, @sf_count) + when Integer + @sf_count -= rv + @sf_offset += rv + when :wait_writable, :wait_readable + return rv + else + raise "BUG: #{rv.nil ? "EOF" : rv.inspect} on tmpio " \ + "sf_offset=#@sf_offset sf_count=#@sf_count" + end while @sf_count > 0 + nil + end + + # called by last wbuf_flush + def wbuf_close(client) + @tmpio = @tmpio.close + wbuf_close_common(client) + end +end diff --git a/lib/yahns/wbuf_common.rb b/lib/yahns/wbuf_common.rb new file mode 100644 index 0000000..e621311 --- /dev/null +++ b/lib/yahns/wbuf_common.rb @@ -0,0 +1,32 @@ +# -*- encoding: binary -*- +# Copyright (C) 2009-2013, Eric Wong <normalperson@yhbt.net> et. al. +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require 'sendfile' +module Yahns::WbufCommon # :nodoc: + # returns nil on success, :wait_*able when blocked + # currently, we rely on each thread having exclusive access to the + # client socket, so this is never called concurrently with wbuf_write + def wbuf_flush(client) + case rv = client.trysendfile(@tmpio, @sf_offset, @sf_count) + when Integer + return wbuf_close(client) if (@sf_count -= rv) == 0 # all sent! + + @sf_offset += rv # keep going otherwise + when :wait_writable, :wait_readable + return rv + else + raise "BUG: #{rv.nil? ? "EOF" : rv.inspect} on tmpio=#{@tmpio.inspect} " \ + "sf_offset=#@sf_offset sf_count=#@sf_count" + end while true + end + + def wbuf_close_common(client) + @body.close if @body.respond_to?(:close) + if @wbuf_persist.respond_to?(:call) # hijack + @wbuf_persist.call(client) + :delete + else + @wbuf_persist # true or false or Yahns::StreamFile + end + end +end diff --git a/lib/yahns/worker.rb b/lib/yahns/worker.rb new file mode 100644 index 0000000..980f7bd --- /dev/null +++ b/lib/yahns/worker.rb @@ -0,0 +1,58 @@ +# -*- encoding: binary -*- +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +class Yahns::Worker # :nodoc: + attr_accessor :nr + attr_reader :to_io + + def initialize(nr) + @nr = nr + @to_io, @wr = Kgio::Pipe.new + end + + def atfork_child + @wr = @wr.close # nil @wr to save space in worker process + end + + def atfork_parent + @to_io = @to_io.close + self + end + + # used in the worker process. + # This causes the worker to gracefully exit if the master + # dies unexpectedly. + def yahns_step + @to_io.kgio_tryread(11) == nil and Process.kill(:QUIT, $$) + :wait_readable + end + + # worker objects may be compared to just plain Integers + def ==(other_nr) # :nodoc: + @nr == other_nr + end + + # Changes the worker process to the specified +user+ and +group+ + # This is only intended to be called from within the worker + # process from the +after_fork+ hook. This should be called in + # the +after_fork+ hook after any privileged functions need to be + # run (e.g. to set per-worker CPU affinity, niceness, etc) + # + # Any and all errors raised within this method will be propagated + # directly back to the caller (usually the +after_fork+ hook. + # These errors commonly include ArgumentError for specifying an + # invalid user/group and Errno::EPERM for insufficient privileges + def user(user, group = nil) + # we do not protect the caller, checking Process.euid == 0 is + # insufficient because modern systems have fine-grained + # capabilities. Let the caller handle any and all errors. + uid = Etc.getpwnam(user).uid + gid = Etc.getgrnam(group).gid if group + Yahns::Log.chown_all(uid, gid) + if gid && Process.egid != gid + Process.initgroups(user, gid) + Process::GID.change_privilege(gid) + end + Process.euid != uid and Process::UID.change_privilege(uid) + end +end diff --git a/test/covshow.rb b/test/covshow.rb new file mode 100644 index 0000000..2fd48c6 --- /dev/null +++ b/test/covshow.rb @@ -0,0 +1,29 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +# +# this works with the __covmerge method in test/helper.rb +# run this file after all tests are run + +# load the merged dump data +res = Marshal.load(IO.binread("coverage.dump")) + +# Dirty little text formatter. I tried simplecov but the default +# HTML+JS is unusable without a GUI (I hate GUIs :P) and it would've +# taken me longer to search the Internets to find a plain-text +# formatter I like... +res.keys.sort.each do |filename| + cov = res[filename] + puts "==> #{filename} <==" + File.readlines(filename).each_with_index do |line, i| + n = cov[i] + if n == 0 # BAD + print(" *** 0 #{line}") + elsif n + printf("% 7u %s", n, line) + elsif line =~ /\S/ # probably a line with just "end" in it + print(" #{line}") + else # blank line + print "\n" # don't output trailing whitespace on blank lines + end + end +end diff --git a/test/helper.rb b/test/helper.rb new file mode 100644 index 0000000..ab9a04f --- /dev/null +++ b/test/helper.rb @@ -0,0 +1,115 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +$stdout.sync = $stderr.sync = Thread.abort_on_exception = true +require 'thread' + +# Global Test Lock, to protect: +# Process.wait*, Dir.chdir, ENV, trap, require, etc... +GTL = Mutex.new + +# fork-aware coverage data gatherer, see also test/covshow.rb +if ENV["COVERAGE"] + require "coverage" + COVMATCH = %r{/lib/yahns\b.*rb\z} + COVTMP = File.open("coverage.dump", IO::CREAT|IO::RDWR) + COVTMP.binmode + COVTMP.sync = true + + def __covmerge + res = Coverage.result + + # we own this file (at least until somebody tries to use NFS :x) + COVTMP.flock(File::LOCK_EX) + + COVTMP.rewind + prev = COVTMP.read + prev = prev.empty? ? {} : Marshal.load(prev) + res.each do |filename, counts| + # filter out stuff that's not in our project + COVMATCH =~ filename or next + + merge = prev[filename] || [] + merge = merge + counts.each_with_index do |count, i| + count or next + merge[i] = (merge[i] || 0) + count + end + prev[filename] = merge + end + COVTMP.rewind + COVTMP.truncate(0) + COVTMP.write(Marshal.dump(prev)) + COVTMP.flock(File::LOCK_UN) + end + + Coverage.start + at_exit { at_exit { __covmerge } } +end + +gem 'minitest' +require 'minitest/autorun' +require "tempfile" + +Testcase = begin + Minitest::Test # minitest 5 +rescue NameError + Minitest::Unit::TestCase # minitest 4 +end + +FIFOS = [] +def tmpfifo + tmp = Tempfile.new(%w(yahns-test .fifo)) + path = tmp.path + tmp.close! + assert system(*%W(mkfifo #{path})), "mkfifo #{path}" + + GTL.synchronize do + if FIFOS.empty? + at_exit do + FIFOS.each { |(pid,_path)| File.unlink(_path) if $$ == pid } + end + end + FIFOS << [ $$, path ] + end + path +end + +require 'tmpdir' +class Dir + require 'fileutils' + def Dir.mktmpdir + begin + d = "#{Dir.tmpdir}/#$$.#{rand}" + Dir.mkdir(d) + rescue Errno::EEXIST + end while true + begin + yield d + ensure + FileUtils.remove_entry(d) + end + end +end unless Dir.respond_to?(:mktmpdir) + +def tmpfile(*args) + tmp = Tempfile.new(*args) + tmp.sync = true + tmp.binmode + tmp +end + +require 'io/wait' +# needed for Rubinius 2.0.0, we only use IO#nread in tests +class IO + # this ignores buffers + def nread + buf = "\0" * 8 + ioctl(0x541B, buf) + buf.unpack("l_")[0] + end +end if ! IO.method_defined?(:nread) && RUBY_PLATFORM =~ /linux/ + +require 'yahns' + +# needed for parallel (MT) tests) +require 'yahns/rack' diff --git a/test/server_helper.rb b/test/server_helper.rb new file mode 100644 index 0000000..78d2f94 --- /dev/null +++ b/test/server_helper.rb @@ -0,0 +1,64 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative 'helper' +require 'timeout' +require 'socket' +require 'net/http' + +module ServerHelper + def check_err(err = @err) + err = File.open(err.path, "r") if err.respond_to?(:path) + err.rewind + lines = err.readlines.delete_if { |l| l =~ /INFO/ } + assert lines.empty?, lines.join("\n") + err.close + end + + def poke_until_dead(pid) + Timeout.timeout(10) do + begin + Process.kill(0, pid) + sleep(0.01) + rescue Errno::ESRCH + break + end while true + end + assert_raises(Errno::ESRCH) { Process.kill(0, pid) } + end + + def quit_wait(pid) + pid or return + Process.kill(:QUIT, pid) + _, status = Timeout.timeout(10) { Process.waitpid2(pid) } + assert status.success?, status.inspect + rescue Timeout::Error + if RUBY_PLATFORM =~ /linux/ + system("lsof -p #{pid}") + warn "#{pid} failed to die, waiting for user to inspect" + sleep + end + raise + end + + def get_tcp_client(host, port, tries = 500) + begin + c = TCPSocket.new(host, port) + return c + rescue Errno::ECONNREFUSED + raise if tries < 0 + tries -= 1 + end while sleep(0.01) + end + + def server_helper_teardown + @srv.close unless @srv.closed? + @ru.close! if @ru + check_err + end + + def server_helper_setup + @srv = TCPServer.new(ENV["TEST_HOST"] || "127.0.0.1", 0) + @err = tmpfile(%w(srv .err)) + @ru = nil + end +end diff --git a/test/test_bin.rb b/test/test_bin.rb new file mode 100644 index 0000000..4a47a93 --- /dev/null +++ b/test/test_bin.rb @@ -0,0 +1,98 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative 'server_helper' +class TestBin < Testcase + parallelize_me! + include ServerHelper + alias teardown server_helper_teardown + + def setup + server_helper_setup + @cmd = %W(ruby -I lib bin/yahns) + end + + def test_bin_daemon_noworker_inherit + bin_daemon(false, true) + end + + def test_bin_daemon_worker_inherit + bin_daemon(true, true) + end + + def test_bin_daemon_noworker_bind + bin_daemon(false, false) + end + + def test_bin_daemon_worker_bind + bin_daemon(true, false) + end + + def bin_daemon(worker, inherit) + @srv.close unless inherit + @pid = tmpfile(%w(test_bin_daemon .pid)) + @ru = tmpfile(%w(test_bin_daemon .ru)) + @ru.write("require 'rack/lobster'; run Rack::Lobster.new\n") + cfg = tmpfile(%w(test_bin_daemon_conf .rb)) + pid = tmpfile(%w(daemon .pid)) + cfg.puts "pid '#{@pid.path}'" + cfg.puts "stderr_path '#{@err.path}'" + cfg.puts "worker_processes 1" if worker + cfg.puts "app(:rack, '#{@ru.path}', preload: false) do" + cfg.puts " listen ENV['YAHNS_TEST_LISTEN']" + cfg.puts "end" + @cmd.concat(%W(-D -c #{cfg.path})) + addr = IO.pipe + pid = fork do + if inherit + @cmd << { @srv.fileno => @srv } + ENV["YAHNS_FD"] = @srv.fileno.to_s + else + @srv = TCPServer.new(ENV["TEST_HOST"] || "127.0.0.1", 0) + end + host, port = @srv.addr[3], @srv.addr[1] + listen = ENV["YAHNS_TEST_LISTEN"] = "#{host}:#{port}" + addr[1].write(listen) + addr[1].close + addr[0].close + exec(*@cmd) + end + addr[1].close + listen = Timeout.timeout(10) { addr[0].read } + addr[0].close + host, port = listen.split(/:/, 2) + port = port.to_i + assert_operator port, :>, 0 + + unless inherit + # daemon_pipe guarantees socket will be usable after this: + Timeout.timeout(10) do # Ruby startup is slow! + _, status = Process.waitpid2(pid) + assert status.success?, status.inspect + end + end + + Net::HTTP.start(host, port) do |http| + req = Net::HTTP::Get.new("/") + res = http.request(req) + assert_equal 200, res.code.to_i + assert_equal "keep-alive", res["Connection"] + end + rescue => e + warn "#{e.message} (#{e.class})" + e.backtrace.each { |l| warn "#{l}" } + raise + ensure + cfg.close! if cfg + pid = File.read(@pid.path) + pid = pid.to_i + assert_operator pid, :>, 0 + Process.kill(:QUIT, pid) + if inherit + _, status = Timeout.timeout(10) { Process.waitpid2(pid) } + assert status.success?, status.inspect + else + poke_until_dead pid + end + @pid.close! if @pid + end +end diff --git a/test/test_config.rb b/test/test_config.rb new file mode 100644 index 0000000..2afcecb --- /dev/null +++ b/test/test_config.rb @@ -0,0 +1,56 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative 'helper' +require 'rack/lobster' + +class TestConfig < Testcase + parallelize_me! + + def test_initialize + cfg = Yahns::Config.new + assert_instance_of Yahns::Config, cfg + end + + def test_multi_conf_example + tmpdir = Dir.mktmpdir + + # modify the example config file for testing + path = "examples/yahns_multi.conf.rb" + cfgs = File.read(path) + cfgs.gsub!(%r{/path/to/}, "#{tmpdir}/") + conf = File.open("#{tmpdir}/yahns_multi.conf.rb", "w") + conf.sync = true + conf.write(cfgs) + File.open("#{tmpdir}/another.ru", "w") do |fp| + fp.puts("run Rack::Lobster.new\n") + end + FileUtils.mkpath("#{tmpdir}/another") + + cfg = GTL.synchronize { Yahns::Config.new(conf.path) } + assert_instance_of Yahns::Config, cfg + ensure + FileUtils.rm_rf(tmpdir) if tmpdir + end + + def test_rack_basic_conf_example + tmpdir = Dir.mktmpdir + + # modify the example config file for testing + path = "examples/yahns_rack_basic.conf.rb" + cfgs = File.read(path) + cfgs.gsub!(%r{/path/to/}, "#{tmpdir}/") + Dir.mkdir("#{tmpdir}/my_app") + Dir.mkdir("#{tmpdir}/my_logs") + Dir.mkdir("#{tmpdir}/my_pids") + conf = File.open("#{tmpdir}/yahns_rack_basic.conf.rb", "w") + conf.sync = true + conf.write(cfgs) + File.open("#{tmpdir}/my_app/config.ru", "w") do |fp| + fp.puts("run Rack::Lobster.new\n") + end + cfg = GTL.synchronize { Yahns::Config.new(conf.path) } + assert_instance_of Yahns::Config, cfg + ensure + FileUtils.rm_rf(tmpdir) if tmpdir + end +end diff --git a/test/test_output_buffering.rb b/test/test_output_buffering.rb new file mode 100644 index 0000000..6fe22ba --- /dev/null +++ b/test/test_output_buffering.rb @@ -0,0 +1,288 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative 'server_helper' +require 'digest/md5' +require 'rack/file' + +class TestOutputBuffering < Testcase + parallelize_me! + include ServerHelper + alias setup server_helper_setup + alias teardown server_helper_teardown + + GPLv3 = File.read("COPYING") + RAND = IO.binread("/dev/urandom", 666) * 119 + dig = Digest::MD5.new + NR = 1337 + MD5 = Thread.new do + NR.times { dig << RAND } + dig.hexdigest + end + + class BigBody + def each + NR.times { yield RAND } + end + end + + def test_output_buffer_false_curl + output_buffer(false, :curl) + end + + def test_output_buffer_false_http09 + output_buffer(false, :http09) + end + + def test_output_buffer_true_curl + output_buffer(true, :curl) + end + + def test_output_buffer_true_http09 + output_buffer(true, :http09) + end + + def output_buffer(btype, check_type, delay = 4) + err = @err + cfg = Yahns::Config.new + host, port = @srv.addr[3], @srv.addr[1] + len = (RAND.size * NR).to_s + cfg.instance_eval do + ru = lambda do |e| + [ 200, {'Content-Length'=>len}, BigBody.new ] + end + GTL.synchronize do + app(:rack, ru) do + listen "#{host}:#{port}" + output_buffering btype + end + end + logger(Logger.new(err.path)) + end + srv = Yahns::Server.new(cfg) + pid = fork do + ENV["YAHNS_FD"] = @srv.fileno.to_s + srv.start.join + end + + case check_type + when :curl + # curl is faster for piping gigantic wads of data than Net::HTTP + sh_sleep = delay ? "sleep #{delay} && " : "" + md5 = `curl -sSf http://#{host}:#{port}/ | (#{sh_sleep} md5sum)` + assert $?.success?, $?.inspect + (md5 =~ /([a-f0-9]{32})/i) or raise "bad md5: #{md5.inspect}" + md5 = $1 + assert_equal MD5.value, md5 + when :http09 + # HTTP/0.9 + c = TCPSocket.new(host, port) + c.write("GET /\r\n\r\n") + md5in = IO.pipe + md5out = IO.pipe + sleep(delay) if delay + md5pid = Process.spawn("md5sum", :in => md5in[0], :out => md5out[1]) + md5in[0].close + md5out[1].close + assert_equal(NR * RAND.size, IO.copy_stream(c, md5in[1])) + c.close + md5in[1].close + _, status = Timeout.timeout(10) { Process.waitpid2(md5pid) } + assert status.success?, status.inspect + md5 = md5out[0].read + (md5 =~ /([a-f0-9]{32})/i) or raise "bad md5: #{md5.inspect}" + md5 = $1 + assert_equal MD5.value, md5 + md5out[0].close + else + raise "TESTBUG" + end + rescue => e + Yahns::Log.exception(Logger.new($stderr), "test", e) + raise + ensure + quit_wait(pid) + end + + class BigHeader + A = "A" * 65536 + def initialize(h) + @h = h + end + def each + NR.times do |n| + yield("X-#{n}", A) + end + @h.each { |k,v| yield(k,v) } + end + end + + def test_big_header + err = @err + cfg = Yahns::Config.new + host, port = @srv.addr[3], @srv.addr[1] + cfg.instance_eval do + ru = lambda do |e| + case e["PATH_INFO"] + when "/COPYING" + Rack::File.new(Dir.pwd).call(e) + gplv3 = File.open("COPYING") + def gplv3.each + raise "SHOULD NOT BE CALLED" + end + size = gplv3.stat.size + len = size.to_s + ranges = Rack::Utils.byte_ranges(e, size) + status = 200 + h = { "Content-Type" => "text/plain", "Content-Length" => len } + if ranges && ranges.size == 1 + status = 206 + range = ranges[0] + h["Content-Range"] = "bytes #{range.begin}-#{range.end}/#{size}" + size = range.end - range.begin + 1 + len.replace(size.to_s) + end + [ status , BigHeader.new(h), gplv3 ] + when "/" + h = { "Content-Type" => "text/plain", "Content-Length" => "4" } + [ 200, BigHeader.new(h), ["BIG\n"] ] + else + raise "WTF" + end + end + GTL.synchronize do + app(:rack, ru) do + listen "#{host}:#{port}" + end + end + logger(Logger.new(err.path)) + end + srv = Yahns::Server.new(cfg) + pid = fork do + ENV["YAHNS_FD"] = @srv.fileno.to_s + srv.start.join + end + threads = [] + + # start with just a big header + threads << Thread.new do + c = TCPSocket.new(host, port) + c.write "GET / HTTP/1.0\r\n\r\n" + begin + sleep 1 + end while c.nread == 0 + nr = 0 + last = nil + c.each_line do |line| + case line + when %r{\AX-} then nr += 1 + else + last = line + end + end + assert_equal NR, nr + assert_equal "BIG\n", last + c.close + end + + threads << Thread.new do + c = TCPSocket.new(host, port) + c.write "GET /COPYING HTTP/1.0\r\n\r\n" + begin + sleep 1 + end while c.nread == 0 + nr = 0 + c.each_line do |line| + case line + when %r{\AX-} then nr += 1 + else + break if line == "\r\n" + end + end + assert_equal NR, nr + assert_equal GPLv3, c.read + c.close + end + + threads << Thread.new do + c = TCPSocket.new(host, port) + c.write "GET /COPYING HTTP/1.0\r\nRange: bytes=5-46\r\n\r\n" + begin + sleep 1 + end while c.nread == 0 + nr = 0 + c.each_line do |line| + case line + when %r{\AX-} then nr += 1 + else + break if line == "\r\n" + end + end + assert_equal NR, nr + assert_equal GPLv3[5..46], c.read + c.close + end + threads.each do |t| + assert_equal t, t.join(30) + assert_nil t.value + end + ensure + quit_wait(pid) + end + + def test_client_timeout + err = @err + apperr = tmpfile(%w(app .err)) + cfg = Yahns::Config.new + size = RAND.size * NR + host, port = @srv.addr[3], @srv.addr[1] + cfg.instance_eval do + ru = lambda do |e| + if e["PATH_INFO"] == "/bh" + h = { "Content-Type" => "text/plain", "Content-Length" => "4" } + [ 200, BigHeader.new(h), ["BIG\n"] ] + else + [ 200, {'Content-Length' => size.to_s }, BigBody.new ] + end + end + GTL.synchronize do + app(:rack, ru) do + listen "#{host}:#{port}" + output_buffering false + client_timeout 3 + logger(Logger.new(apperr.path)) + end + end + logger(Logger.new(err.path)) + end + srv = Yahns::Server.new(cfg) + pid = fork do + ENV["YAHNS_FD"] = @srv.fileno.to_s + srv.start.join + end + threads = [] + threads << Thread.new do + c = get_tcp_client(host, port) + c.write("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n") + sleep(5) # wait for timeout + assert_operator c.nread, :>, 0 + c + end + + threads << Thread.new do + c = get_tcp_client(host, port) + c.write("GET /bh HTTP/1.1\r\nHost: example.com\r\n\r\n") + sleep(5) # wait for timeout + assert_operator c.nread, :>, 0 + c + end + threads.each { |t| t.join(10) } + assert_operator size, :>, threads[0].value.read.size + assert_operator size, :>, threads[1].value.read.size + msg = File.readlines(apperr.path) + msg = msg.grep(/timeout on :wait_writable after 3s$/) + assert_equal 2, msg.size + ensure + quit_wait(pid) + end +end if `which curl 2>/dev/null`.strip =~ /curl/ && + `which md5sum 2>/dev/null`.strip =~ /md5sum/ diff --git a/test/test_queue.rb b/test/test_queue.rb new file mode 100644 index 0000000..6d61aef --- /dev/null +++ b/test/test_queue.rb @@ -0,0 +1,59 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative 'helper' +require 'timeout' +require 'stringio' + +class TestQueue < Testcase + parallelize_me! + + def setup + @q = Yahns::Queue.new + @err = StringIO.new + @logger = Logger.new(@err) + @q.fdmap = @fdmap = Yahns::Fdmap.new(@logger, 0.5) + assert @q.close_on_exec? + end + + def test_queue + r, w = IO.pipe + assert_equal 0, @fdmap.size + @q.queue_add(r, Yahns::Queue::QEV_RD) + assert_equal 1, @fdmap.size + def r.yahns_step + begin + case read_nonblock(11) + when "delete" + return :delete + end + rescue Errno::EAGAIN + return :wait_readable + rescue EOFError + return nil + end while true + end + w.write('.') + Timeout.timeout(10) do + Thread.pass until r.nread > 0 + @q.spawn_worker_threads(@logger, 1, 1) + Thread.pass until r.nread == 0 + + w.write("delete") + Thread.pass until r.nread == 0 + Thread.pass until @fdmap.size == 0 + + # should not raise + @q.queue_add(r, Yahns::Queue::QEV_RD) + assert_equal 1, @fdmap.size + w.close + Thread.pass until @fdmap.size == 0 + end + assert r.closed? + ensure + [ r, w ].each { |io| io.close unless io.closed? } + end + + def teardown + @q.close + end +end diff --git a/test/test_rack.rb b/test/test_rack.rb new file mode 100644 index 0000000..bd0d5b5 --- /dev/null +++ b/test/test_rack.rb @@ -0,0 +1,26 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative 'helper' +require 'rack/lobster' +require 'yahns/rack' +class TestRack < Testcase + parallelize_me! + + def test_rack + tmp = tmpfile(%W(config .ru)) + tmp.write "run Rack::Lobster.new\n" + rapp = GTL.synchronize { Yahns::Rack.new(tmp.path) } + assert_kind_of Rack::Lobster, GTL.synchronize { rapp.app_after_fork } + defaults = rapp.app_defaults + assert_kind_of Hash, defaults + end + + def test_rack_preload + tmp = tmpfile(%W(config .ru)) + tmp.write "run Rack::Lobster.new\n" + rapp = GTL.synchronize { Yahns::Rack.new(tmp.path, preload: true) } + assert_kind_of Rack::Lobster, rapp.instance_variable_get(:@app) + defaults = rapp.app_defaults + assert_kind_of Hash, defaults + end +end diff --git a/test/test_serve_static.rb b/test/test_serve_static.rb new file mode 100644 index 0000000..b9856e9 --- /dev/null +++ b/test/test_serve_static.rb @@ -0,0 +1,42 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative 'server_helper' +require 'rack/file' + +class TestServeStatic < Testcase + parallelize_me! + include ServerHelper + alias setup server_helper_setup + alias teardown server_helper_teardown + + def test_serve_static + err = @err + cfg = Yahns::Config.new + host, port = @srv.addr[3], @srv.addr[1] + cfg.instance_eval do + GTL.synchronize do + app(:rack, Rack::File.new(Dir.pwd)) { listen "#{host}:#{port}" } + end + logger(Logger.new(err.path)) + end + srv = Yahns::Server.new(cfg) + pid = fork do + ENV["YAHNS_FD"] = @srv.fileno.to_s + srv.start.join + end + gplv3 = File.read("COPYING") + Net::HTTP.start(host, port) do |http| + res = http.request(Net::HTTP::Get.new("/COPYING")) + assert_equal gplv3, res.body + + req = Net::HTTP::Get.new("/COPYING", "Range" => "bytes=5-46") + res = http.request(req) + assert_equal gplv3[5..46], res.body + end + rescue => e + Yahns::Log.exception(Logger.new($stderr), "test", e) + raise + ensure + quit_wait(pid) + end +end diff --git a/test/test_server.rb b/test/test_server.rb new file mode 100644 index 0000000..5c86268 --- /dev/null +++ b/test/test_server.rb @@ -0,0 +1,382 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative 'server_helper' + +class TestServer < Testcase + parallelize_me! + include ServerHelper + + alias setup server_helper_setup + alias teardown server_helper_teardown + + def test_single_process + err = @err + cfg = Yahns::Config.new + host, port = @srv.addr[3], @srv.addr[1] + cfg.instance_eval do + ru = lambda { |_| [ 200, {'Content-Length'=>'2'}, ['HI'] ] } + GTL.synchronize { app(:rack, ru) { listen "#{host}:#{port}" } } + logger(Logger.new(err.path)) + end + srv = Yahns::Server.new(cfg) + pid = fork do + ENV["YAHNS_FD"] = @srv.fileno.to_s + srv.start.join + end + run_client(host, port) { |res| assert_equal "HI", res.body } + c = get_tcp_client(host, port) + + # test pipelining + r = "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n" + c.write(r + r) + buf = "" + Timeout.timeout(10) do + until buf =~ /HI.+HI/m + buf << c.readpartial(4096) + end + end + + # trickle pipelining + c.write(r + "GET ") + buf = "" + Timeout.timeout(10) do + until buf =~ /HI\z/ + buf << c.readpartial(4096) + end + end + c.write("/ HTTP/1.1\r\nHost: example.com\r\n\r\n") + Timeout.timeout(10) do + until buf =~ /HI.+HI/m + buf << c.readpartial(4096) + end + end + rescue => e + Yahns::Log.exception(Logger.new($stderr), "test", e) + raise + ensure + c.close if c + quit_wait(pid) + end + + def test_input_body_true; input_body(true); end + def test_input_body_false; input_body(false); end + def test_input_body_lazy; input_body(:lazy); end + + def input_body(btype) + err = @err + cfg = Yahns::Config.new + host, port = @srv.addr[3], @srv.addr[1] + cfg.instance_eval do + ru = lambda {|e|[ 200, {'Content-Length'=>'2'},[e["rack.input"].read]]} + GTL.synchronize do + app(:rack, ru) do + listen "#{host}:#{port}" + input_buffering btype + end + end + logger(Logger.new(err.path)) + end + srv = Yahns::Server.new(cfg) + pid = fork do + ENV["YAHNS_FD"] = @srv.fileno.to_s + srv.start.join + end + c = get_tcp_client(host, port) + buf = "PUT / HTTP/1.0\r\nContent-Length: 2\r\n\r\nHI" + c.write(buf) + IO.select([c], nil, nil, 5) + rv = c.read(666) + head, body = rv.split(/\r\n\r\n/) + assert_match(%r{^Content-Length: 2\r\n}, head) + assert_equal "HI", body, "#{rv.inspect} - #{btype.inspect}" + c.close + + # pipelined oneshot + buf = "PUT / HTTP/1.1\r\nContent-Length: 2\r\n\r\nHI" + c = get_tcp_client(host, port) + c.write(buf + buf) + buf = "" + Timeout.timeout(10) do + until buf =~ /HI.+HI/m + buf << c.readpartial(4096) + end + end + assert buf.gsub!(/Date:[^\r\n]+\r\n/, ""), "kill differing Date" + rv = buf.sub!(/\A(HTTP.+?\r\n\r\nHI)/m, "") + first = $1 + assert rv + assert_equal first, buf + + # pipelined trickle + buf = "PUT / HTTP/1.1\r\nContent-Length: 5\r\n\r\nHIBYE" + (buf + buf).each_byte do |b| + c.write(b.chr) + sleep(0.01) if b.chr == ":" + Thread.pass + end + buf = "" + Timeout.timeout(10) do + until buf =~ /HIBYE.+HIBYE/m + buf << c.readpartial(4096) + end + end + assert buf.gsub!(/Date:[^\r\n]+\r\n/, ""), "kill differing Date" + rv = buf.sub!(/\A(HTTP.+?\r\n\r\nHIBYE)/m, "") + first = $1 + assert rv + assert_equal first, buf + rescue => e + Yahns::Log.exception(Logger.new($stderr), "test", e) + raise + ensure + c.close if c + quit_wait(pid) + end + + def test_trailer_true; trailer(true); end + def test_trailer_false; trailer(false); end + def test_trailer_lazy; trailer(:lazy); end + def test_slow_trailer_true; trailer(true, 0.02); end + def test_slow_trailer_false; trailer(false, 0.02); end + def test_slow_trailer_lazy; trailer(:lazy, 0.02); end + + def trailer(btype, delay = false) + err = @err + cfg = Yahns::Config.new + host, port = @srv.addr[3], @srv.addr[1] + cfg.instance_eval do + ru = lambda do |e| + body = e["rack.input"].read + s = e["HTTP_XBT"] + "\n" + body + [ 200, {'Content-Length'=>s.size.to_s}, [ s ] ] + end + GTL.synchronize do + app(:rack, ru) do + listen "#{host}:#{port}" + input_buffering btype + end + end + logger(Logger.new(err.path)) + end + srv = Yahns::Server.new(cfg) + pid = fork do + ENV["YAHNS_FD"] = @srv.fileno.to_s + srv.start.join + end + c = get_tcp_client(host, port) + buf = "PUT / HTTP/1.0\r\nTrailer:xbt\r\nTransfer-Encoding: chunked\r\n\r\n" + c.write(buf) + xbt = btype.to_s + sleep(delay) if delay + c.write(sprintf("%x\r\n", xbt.size)) + sleep(delay) if delay + c.write(xbt) + sleep(delay) if delay + c.write("\r\n") + sleep(delay) if delay + c.write("0\r\nXBT: ") + sleep(delay) if delay + c.write("#{xbt}\r\n\r\n") + IO.select([c], nil, nil, 5000) or raise "timed out" + rv = c.read(666) + _, body = rv.split(/\r\n\r\n/) + a, b = body.split(/\n/) + assert_equal xbt, a + assert_equal xbt, b + ensure + c.close if c + quit_wait(pid) + end + + def test_check_client_connection + msgs = %w(ZZ zz) + err = @err + cfg = Yahns::Config.new + bpipe = IO.pipe + host, port = @srv.addr[3], @srv.addr[1] + cfg.instance_eval do + ru = lambda { |e| + case e['PATH_INFO'] + when '/sleep' + a = Object.new + a.instance_variable_set(:@bpipe, bpipe) + a.instance_variable_set(:@msgs, msgs) + def a.each + @msgs.each do |msg| + yield @bpipe[0].read(msg.size) + end + end + when '/cccfail' + # we should not get here if check_client_connection worked + abort "CCCFAIL" + else + a = %w(HI) + end + [ 200, {'Content-Length'=>'2'}, a ] + } + GTL.synchronize { + app(:rack, ru) { + listen "#{host}:#{port}" + check_client_connection true + # needed to avoid concurrency with check_client_connection + queue { worker_threads 1 } + output_buffering false + } + } + logger(Logger.new(err.path)) + end + srv = Yahns::Server.new(cfg) + + # ensure we set worker_threads correctly + eggs = srv.instance_variable_get(:@config).qeggs + assert_equal 1, eggs.size + assert_equal 1, eggs[:default].instance_variable_get(:@worker_threads) + + pid = fork do + bpipe[1].close + ENV["YAHNS_FD"] = @srv.fileno.to_s + srv.start.join + end + bpipe[0].close + a = get_tcp_client(host, port) + b = get_tcp_client(host, port) + a.write("GET /sleep HTTP/1.0\r\n\r\n") + r = IO.select([a], nil, nil, 4) + assert r, "nothing ready" + assert_equal a, r[0][0] + buf = a.read(8) + assert_equal "HTTP/1.1", buf + + # hope the kernel sees this before it sees the bpipe ping-ponging below + b.write("GET /cccfail HTTP/1.0\r\n\r\n") + b.shutdown + b.close + + # ping-pong a bit to stall the server + msgs.each do |msg| + bpipe[1].write(msg) + Timeout.timeout(10) { buf << a.readpartial(10) until buf =~ /#{msg}/ } + end + bpipe[1].close + assert_equal msgs.join, buf.split(/\r\n\r\n/)[1] + + # do things still work? + run_client(host, port) { |res| assert_equal "HI", res.body } + ensure + quit_wait(pid) + end + + def test_mp + pid, host, port = new_mp_server + wpid = nil + run_client(host, port) do |res| + wpid ||= res.body.to_i + end + ensure + quit_wait(pid) + if wpid + assert_raises(Errno::ESRCH) { Process.kill(:KILL, wpid) } + assert_raises(Errno::ECHILD) { Process.waitpid2(wpid) } + end + end + + # Linux blocking accept() has fair behavior between multiple tasks + def test_mp_balance + skip("linux-only test") unless RUBY_PLATFORM =~ /linux/ + pid, host, port = new_mp_server(2) + seen = {} + + # wait for both processes to spin up + Timeout.timeout(10) do + run_client(host, port) { |res| seen[res.body] = 1 } until seen.size == 2 + end + + prev = nil + req = Net::HTTP::Get.new("/") + # we should bounce new connections between 2 processes + 4.times do + Net::HTTP.start(host, port) do |http| + res = http.request(req) + assert_equal 200, res.code.to_i + assert_equal "keep-alive", res["Connection"] + refute_equal prev, res.body, "same PID accepted twice" + prev = res.body.dup + seen[prev] += 1 + 666.times { Thread.pass } # have the other acceptor to wake up + end + end + assert_equal 2, seen.size + ensure + quit_wait(pid) + end + + def test_mp_worker_die + pid, host, port = new_mp_server + wpid1 = wpid2 = nil + run_client(host, port) do |res| + wpid1 ||= res.body.to_i + end + Process.kill(:QUIT, wpid1) + poke_until_dead(wpid1) + run_client(host, port) do |res| + wpid2 ||= res.body.to_i + end + refute_equal wpid2, wpid1 + ensure + quit_wait(pid) + assert_raises(Errno::ESRCH) { Process.kill(:KILL, wpid2) } if wpid2 + end + + def test_mp_dead_parent + pid, host, port = new_mp_server + wpid = nil + run_client(host, port) do |res| + wpid ||= res.body.to_i + end + Process.kill(:KILL, pid) + _, status = Process.waitpid2(pid) + assert status.signaled?, status.inspect + poke_until_dead(wpid) + end + + def run_client(host, port) + c = get_tcp_client(host, port) + Net::HTTP.start(host, port) do |http| + res = http.request(Net::HTTP::Get.new("/")) + assert_equal 200, res.code.to_i + assert_equal "keep-alive", res["Connection"] + yield res + res = http.request(Net::HTTP::Get.new("/")) + assert_equal 200, res.code.to_i + assert_equal "keep-alive", res["Connection"] + yield res + end + c.write "GET / HTTP/1.0\r\n\r\n" + res = Timeout.timeout(10) { c.read } + head, _ = res.split(/\r\n\r\n/) + head = head.split(/\r\n/) + assert_equal "HTTP/1.1 200 OK", head[0] + assert_equal "Connection: close", head[-1] + c.close + end + + def new_mp_server(nr = 1) + ru = @ru = tmpfile(%w(config .ru)) + @ru.puts('a = $$.to_s') + @ru.puts('run lambda { |_| [ 200, {"Content-Length"=>a.size.to_s},[a]]}') + err = @err + cfg = Yahns::Config.new + host, port = @srv.addr[3], @srv.addr[1] + cfg.instance_eval do + worker_processes 2 + GTL.synchronize { app(:rack, ru.path) { listen "#{host}:#{port}" } } + logger(Logger.new(File.open(err.path, "a"))) + end + srv = Yahns::Server.new(cfg) + pid = fork do + ENV["YAHNS_FD"] = @srv.fileno.to_s + srv.start.join + end + [ pid, host, port ] + end +end diff --git a/test/test_stream_file.rb b/test/test_stream_file.rb new file mode 100644 index 0000000..6574a97 --- /dev/null +++ b/test/test_stream_file.rb @@ -0,0 +1,30 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative 'helper' +require 'timeout' + +class TestStreamFile < Testcase + parallelize_me! + DevFD = Struct.new(:to_path) + + def test_stream_file + fp = File.open("COPYING") + sf = Yahns::StreamFile.new(fp, true, 0, fp.stat.size) + refute sf.respond_to?(:close) + sf.wbuf_close(nil) + assert fp.closed? + end + + def test_fd + fp = File.open("COPYING") + obj = DevFD.new("/dev/fd/#{fp.fileno}") + sf = Yahns::StreamFile.new(obj, true, 0, fp.stat.size) + io = sf.instance_variable_get :@tmpio + assert_instance_of IO, io.to_io + assert_equal fp.fileno, io.fileno + refute sf.respond_to?(:close) + sf.wbuf_close(nil) + refute fp.closed? + refute io.closed? + end +end diff --git a/test/test_wbuf.rb b/test/test_wbuf.rb new file mode 100644 index 0000000..dc6bc24 --- /dev/null +++ b/test/test_wbuf.rb @@ -0,0 +1,136 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative 'helper' +require 'timeout' + +class TestWbuf < Testcase + parallelize_me! + + def test_wbuf + buf = "*" * (16384 * 2) + nr = 1000 + [ true, false ].each do |persist| + wbuf = Yahns::Wbuf.new([], persist) + a, b = UNIXSocket.pair + assert_nil wbuf.wbuf_write(a, "HIHI") + assert_equal "HIHI", b.read(4) + nr.times { wbuf.wbuf_write(a, buf) } + assert_equal :wait_writable, wbuf.wbuf_flush(a) + done = IO.pipe + thr = Thread.new do + rv = [] + until rv[-1] == persist + IO.select(nil, [a]) + tmp = wbuf.wbuf_flush(a) + rv << tmp + end + done[1].syswrite '.' + rv + end + + wait = true + begin + if wait + r = IO.select([b,done[0]], nil, nil, 5) + end + b.read_nonblock((rand * 1024) + 666, buf) + wait = (r[0] & done).empty? + rescue Errno::EAGAIN + break + end while true + + assert_equal thr, thr.join(5) + rv = thr.value + assert_equal persist, rv.pop + assert(rv.all? { |x| x == :wait_writable }) + a.close + b.close + done.each { |io| io.close } + end + end + + def test_wbuf_blocked + a, b = UNIXSocket.pair + buf = "." * 4096 + 4.times do + begin + a.write_nonblock(buf) + rescue Errno::EAGAIN + break + end while true + end + wbuf = Yahns::Wbuf.new([], true) + assert_equal :wait_writable, wbuf.wbuf_write(a, buf) + assert_equal :wait_writable, wbuf.wbuf_flush(a) + + # drain the buffer + Timeout.timeout(10) { b.read(b.nread) until b.nread == 0 } + + # b.nread will increase after this + assert_nil wbuf.wbuf_write(a, "HI") + nr = b.nread + assert_operator nr, :>, 0 + assert_equal b, IO.select([b], nil, nil, 5)[0][0] + b.read(nr - 2) if nr > 2 + assert_equal b, IO.select([b], nil, nil, 5)[0][0] + assert_equal "HI", b.read(2) + begin + wbuf.wbuf_flush(a) + assert false + rescue => e + end + assert_match(%r{BUG: EOF on tmpio}, e.message) + ensure + a.close + b.close + end + + def test_wbuf_flush_close + pipe = IO.pipe + persist = true + wbuf = Yahns::Wbuf.new(pipe[0], persist) + refute wbuf.respond_to?(:close) # we don't want this for HttpResponse body + sp = UNIXSocket.pair + rv = nil + + buf = ("*" * 16384) << "\n" + thr = Thread.new do + 1000.times { pipe[1].write(buf) } + pipe[1].close + end + + pipe[0].each { |chunk| rv = wbuf.wbuf_write(sp[1], chunk) } + assert_equal thr, thr.join(5) + assert_equal :wait_writable, rv + + done = IO.pipe + thr = Thread.new do + rv = [] + until rv[-1] == persist + IO.select(nil, [sp[1]]) + rv << wbuf.wbuf_flush(sp[1]) + end + done[1].syswrite '.' + rv + end + + wait = true + begin + if wait + r = IO.select([sp[0],done[0]], nil, nil, 5) + end + sp[0].read_nonblock(16384, buf) + wait = (r[0] & done).empty? + rescue Errno::EAGAIN + break + end while true + + assert_equal thr, thr.join(5) + rv = thr.value + assert_equal true, rv.pop + assert rv.all? { |x| x == :wait_writable } + assert pipe[0].closed? + sp.each(&:close) + done.each(&:close) + end +end diff --git a/yahns.gemspec b/yahns.gemspec new file mode 100644 index 0000000..68aad2e --- /dev/null +++ b/yahns.gemspec @@ -0,0 +1,19 @@ +# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +Gem::Specification.new do |s| + manifest = File.read('.gem-manifest').split(/\n/) + s.name = %q{yahns} + s.version = ENV["VERSION"] + s.authors = ["#{name} hackers"] + s.summary = "sleepy, multi-threaded, non-blocking application server" + s.description = File.read("README").split(/\n\n/)[1].strip + s.email = %q{yahns@yhbt.net} + s.executables = manifest.grep(%r{\Abin/}).map { |s| s.sub(%r{\Abin/}, "") } + s.files = manifest + s.add_dependency(%q<kgio>, '~> 2.8') + s.add_dependency(%q<sleepy_penguin>, '~> 3.2') + s.add_dependency(%q<sendfile>, '~> 1.2.1') + s.add_dependency(%q<unicorn>, '~> 4.6.3') + s.homepage = "http://yahns.yhbt.net/README" + s.licenses = "GPLv3+" +end |