From 35178024bfc43a6d20bfbb883236e3d5cde42fa1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=93=8D=E7=9B=90=E7=94=9C=E4=B8=8D=E7=94=9C?=
<51872789+long45343@users.noreply.github.com>
Date: Tue, 18 Aug 2026 23:03:36 +0800
Subject: [PATCH 01/32] Create LICENSE
---
LICENSE | 674 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 674 insertions(+)
create mode 100644 LICENSE
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ 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.
+
+
+ Copyright (C)
+
+ 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 .
+
+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:
+
+ Copyright (C)
+ 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
+.
+
+ 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
+.
From 5a7fe747b26c0237c5c13be06efab806e0376b3e Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Tue, 18 Aug 2026 23:20:40 +0800
Subject: [PATCH 02/32] Add cross-platform release CI
---
.github/workflows/ci.yml | 39 +++++++
.github/workflows/release.yml | 107 +++++++++++++++++++
.gitignore | 1 +
README.md | 22 ++++
TextCascade.Server/TextCascade.Server.csproj | 9 ++
5 files changed, 178 insertions(+)
create mode 100644 .github/workflows/ci.yml
create mode 100644 .github/workflows/release.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4d44bde
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,39 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+
+jobs:
+ build-test:
+ name: Build and test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+ dotnet-quality: ga
+
+ - name: Restore
+ run: dotnet restore TextCascade.Server.slnx
+
+ - name: Build
+ run: dotnet build TextCascade.Server.slnx --configuration Release --no-restore
+
+ - name: Test
+ run: dotnet test TextCascade.Server.slnx --configuration Release --no-build --logger trx --results-directory ./TestResults
+
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results
+ path: ./TestResults
+ if-no-files-found: warn
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..33a35cd
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,107 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*.*.*'
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ name: Build and publish release
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+ dotnet-quality: ga
+
+ - name: Restore
+ run: dotnet restore TextCascade.Server.slnx
+
+ - name: Build
+ run: dotnet build TextCascade.Server.slnx --configuration Release --no-restore
+
+ - name: Test
+ run: dotnet test TextCascade.Server.slnx --configuration Release --no-build
+
+ - name: Resolve version
+ id: version
+ run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
+
+ - name: Publish Windows framework-dependent single file
+ run: >
+ dotnet publish TextCascade.Server/TextCascade.Server.csproj
+ --configuration Release --runtime win-x64 --self-contained false
+ -p:PublishSingleFile=true -p:DebugType=embedded
+ -p:IncludeNativeLibrariesForSelfExtract=true
+ -p:PublishDir=$GITHUB_WORKSPACE/artifacts/win-x64/
+
+ - name: Publish Linux framework-dependent single file
+ run: >
+ dotnet publish TextCascade.Server/TextCascade.Server.csproj
+ --configuration Release --runtime linux-x64 --self-contained false
+ -p:PublishSingleFile=true -p:DebugType=embedded
+ -p:IncludeNativeLibrariesForSelfExtract=true
+ -p:PublishDir=$GITHUB_WORKSPACE/artifacts/linux-x64/
+
+ - name: Stage release files
+ run: |
+ mkdir -p release/textcascade-server-windows-x64 release/textcascade-server-linux-x64
+ cp artifacts/win-x64/TextCascade.Server.exe release/textcascade-server-windows-x64/
+ cp artifacts/linux-x64/TextCascade.Server release/textcascade-server-linux-x64/
+ cp deploy/textcascade.toml release/textcascade-server-windows-x64/
+ cp deploy/textcascade.toml release/textcascade-server-linux-x64/
+ cp deploy/textcascade-server.service release/textcascade-server-linux-x64/
+ chmod +x release/textcascade-server-linux-x64/TextCascade.Server
+ file release/textcascade-server-linux-x64/TextCascade.Server
+ test -f release/textcascade-server-windows-x64/TextCascade.Server.exe
+ test -f release/textcascade-server-linux-x64/TextCascade.Server
+
+ - name: Package Windows archive
+ run: |
+ cd release
+ zip -r ../TextCascade.Server-${{ steps.version.outputs.version }}-windows-x64.zip textcascade-server-windows-x64
+
+ - name: Package Linux archive
+ run: |
+ cd release
+ tar -czf ../TextCascade.Server-${{ steps.version.outputs.version }}-linux-x64.tar.gz textcascade-server-linux-x64
+
+ - name: Generate checksums
+ run: |
+ cd "$GITHUB_WORKSPACE"
+ sha256sum \
+ TextCascade.Server-${{ steps.version.outputs.version }}-windows-x64.zip \
+ TextCascade.Server-${{ steps.version.outputs.version }}-linux-x64.tar.gz \
+ > checksums-sha256.txt
+
+ - name: Upload build artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: textcascade-server-${{ steps.version.outputs.version }}
+ path: |
+ TextCascade.Server-${{ steps.version.outputs.version }}-windows-x64.zip
+ TextCascade.Server-${{ steps.version.outputs.version }}-linux-x64.tar.gz
+ checksums-sha256.txt
+ if-no-files-found: error
+
+ - name: Create GitHub Release
+ if: startsWith(github.ref, 'refs/tags/')
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ github.ref_name }}
+ name: TextCascade Server ${{ github.ref_name }}
+ draft: false
+ prerelease: ${{ contains(github.ref_name, '-') }}
+ files: |
+ TextCascade.Server-${{ steps.version.outputs.version }}-windows-x64.zip
+ TextCascade.Server-${{ steps.version.outputs.version }}-linux-x64.tar.gz
+ checksums-sha256.txt
diff --git a/.gitignore b/.gitignore
index 0ba57b3..b145798 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,3 +58,4 @@ Desktop.ini
*.log
logs/
tmp/
+artifacts/
diff --git a/README.md b/README.md
index 5a67d15..7029e05 100644
--- a/README.md
+++ b/README.md
@@ -144,6 +144,17 @@ dotnet test
测试覆盖协议解析、配置、登录限流、token 服务、用户文件、clip 与核心逻辑。
+
+### 下载与发布
+
+GitHub Release 提供两种 Framework-dependent 单文件包,目标机需预装 .NET 10 Runtime:
+
+- `TextCascade.Server--windows-x64.zip`
+- `TextCascade.Server--linux-x64.tar.gz`
+
+包内附带主程序、配置模板;Linux 包另附 systemd unit。每次 Release 同时提供 SHA-256 校验文件。
+
+推送 `v*.*.*` 标签(如 `v0.2.0`)会自动执行测试、构建双平台单文件包、生成校验和并发布 GitHub Release。`main` 分支和 Pull Request 会自动执行 restore/build/test CI。
### 生产部署(systemd)
参考 `deploy/textcascade-server.service`:
@@ -279,6 +290,17 @@ dotnet test
Covers protocol parsing, config, login limiting, token service, users file, and clip/core logic.
+
+### Downloads and Releases
+
+GitHub Releases provides two framework-dependent single-file archives. The .NET 10 Runtime must be installed on the target machine:
+
+- `TextCascade.Server--windows-x64.zip`
+- `TextCascade.Server--linux-x64.tar.gz`
+
+Each archive contains the executable and config template; the Linux archive also includes the systemd unit. Every Release includes a SHA-256 checksum file.
+
+Pushing a `v*.*.*` tag (for example `v0.2.0`) runs tests, builds both single-file archives, generates checksums, and publishes a GitHub Release. Pushes to `main` and pull requests run restore/build/test CI automatically.
### Production (systemd)
See `deploy/textcascade-server.service`:
diff --git a/TextCascade.Server/TextCascade.Server.csproj b/TextCascade.Server/TextCascade.Server.csproj
index 4e35742..a1b9775 100644
--- a/TextCascade.Server/TextCascade.Server.csproj
+++ b/TextCascade.Server/TextCascade.Server.csproj
@@ -6,6 +6,15 @@
enable
0.2.0
TextCascade.Server
+ true
+
+
+
+ false
+ true
+ true
+ embedded
+ $(RuntimeIdentifier)
From 3a28a44c33484a722f7e63d5d6dcfdb9237c7c07 Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Tue, 18 Aug 2026 23:48:07 +0800
Subject: [PATCH 03/32] Fix server protocol bugs
---
.../ConnectionStateTests.cs | 67 +++++++++++++++++++
.../ProtocolSerializationTests.cs | 47 +++++++++++++
TextCascade.Server/Cli.cs | 35 +++++++---
TextCascade.Server/Protocol.cs | 44 ++++++++----
TextCascade.Server/SyncServer.cs | 44 +++++++++++-
5 files changed, 215 insertions(+), 22 deletions(-)
create mode 100644 TextCascade.Server.Tests/ConnectionStateTests.cs
create mode 100644 TextCascade.Server.Tests/ProtocolSerializationTests.cs
diff --git a/TextCascade.Server.Tests/ConnectionStateTests.cs b/TextCascade.Server.Tests/ConnectionStateTests.cs
new file mode 100644
index 0000000..7b3c6bd
--- /dev/null
+++ b/TextCascade.Server.Tests/ConnectionStateTests.cs
@@ -0,0 +1,67 @@
+using TextCascade.Server;
+
+namespace TextCascade.Server.Tests;
+
+public class ConnectionStateTests
+{
+ [Fact]
+ public void UnsolicitedPongIsRejectedUntilPingIsAwaited()
+ {
+ var state = new ConnectionStateBag(TextCascade.Server.Config.CreateDefaultConfig());
+
+ Assert.False(state.TryTakePongAwaiting());
+ }
+
+ [Fact]
+ public void ExpectedPongIsAcceptedOnceAndThenRejectedAgain()
+ {
+ var state = new ConnectionStateBag(TextCascade.Server.Config.CreateDefaultConfig());
+
+ state.MarkPingAwaitingPong();
+ Assert.True(state.TryTakePongAwaiting());
+ Assert.False(state.TryTakePongAwaiting());
+ }
+}
+
+public class CliPasswordInputTests
+{
+ [Fact]
+ public void DetectsPasswordStdinFlag()
+ {
+ Assert.True(Cli.HasPasswordStdin(new[] { "add", "--username", "alice", "--password-stdin" }));
+ Assert.False(Cli.HasPasswordStdin(new[] { "add", "--username", "alice" }));
+ }
+
+ [Fact]
+ public void PasswordStdinReadsOneLineWithoutConsoleKeyInput()
+ {
+ var original = Console.In;
+ try
+ {
+ Console.SetIn(new StringReader("secret-password\n"));
+ var args = new[] { "add", "--username", "alice", "--password-stdin" };
+ Assert.Equal("secret-password", Cli.ReadPassword("Password: ", args));
+ }
+ finally
+ {
+ Console.SetIn(original);
+ }
+ }
+
+ [Fact]
+ public void PasswordStdinRejectsEmptyInput()
+ {
+ var original = Console.In;
+ try
+ {
+ Console.SetIn(new StringReader(string.Empty));
+ var args = new[] { "hash", "--password-stdin" };
+ Assert.Throws(() => Cli.ReadPassword("Password: ", args));
+ }
+ finally
+ {
+ Console.SetIn(original);
+ }
+ }
+}
+
diff --git a/TextCascade.Server.Tests/ProtocolSerializationTests.cs b/TextCascade.Server.Tests/ProtocolSerializationTests.cs
new file mode 100644
index 0000000..f7e3220
--- /dev/null
+++ b/TextCascade.Server.Tests/ProtocolSerializationTests.cs
@@ -0,0 +1,47 @@
+using System.Text;
+using System.Text.Json;
+using TextCascade.Server;
+
+namespace TextCascade.Server.Tests;
+
+public class ProtocolSerializationTests
+{
+ [Fact]
+ public void WelcomeLatestTimeUsesUtcSecondFormat()
+ {
+ var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(1760000000123).ToUniversalTime();
+ var latest = new LatestText("payload", 7, "hash", true, "client", "name", timestamp);
+
+ var json = Encoding.UTF8.GetString(Protocol.SerializeWelcome(latest));
+
+ using var document = JsonDocument.Parse(json);
+ var actual = document.RootElement.GetProperty("latest").GetProperty("updatedAtUtc").GetString();
+ Assert.Equal("2025-10-09T08:53:20Z", actual);
+ }
+
+ [Fact]
+ public void ClipTimeUsesUtcSecondFormat()
+ {
+ var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(1760000000999).ToUniversalTime();
+ var latest = new LatestText("payload", 7, "hash", true, "client", "name", timestamp);
+
+ var json = Encoding.UTF8.GetString(Protocol.SerializeClip("clip-1", latest));
+
+ using var document = JsonDocument.Parse(json);
+ var actual = document.RootElement.GetProperty("updatedAtUtc").GetString();
+ Assert.Equal("2025-10-09T08:53:20Z", actual);
+ }
+
+ [Fact]
+ public void ClipAckTimeUsesUtcSecondFormat()
+ {
+ var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(1760000000500).ToUniversalTime();
+ var latest = new LatestText("payload", 7, "hash", true, "client", "name", timestamp);
+
+ var json = Encoding.UTF8.GetString(Protocol.SerializeClipAck("clip-1", latest));
+
+ using var document = JsonDocument.Parse(json);
+ var actual = document.RootElement.GetProperty("updatedAtUtc").GetString();
+ Assert.Equal("2025-10-09T08:53:20Z", actual);
+ }
+}
diff --git a/TextCascade.Server/Cli.cs b/TextCascade.Server/Cli.cs
index 2e15c3f..2e45a54 100644
--- a/TextCascade.Server/Cli.cs
+++ b/TextCascade.Server/Cli.cs
@@ -16,9 +16,7 @@ public static int RunCli(string[] args, IPasswordHasher? hasher = null)
{
if (args.Length == 0 || args[0] != "user")
{
- Console.Error.WriteLine("Usage: TextCascade.Server user [options]");
- Console.Error.WriteLine("Commands: add, passwd, disable, enable, delete, revoke-tokens, list, hash");
- return Error;
+ return PrintUsage();
}
hasher ??= new Argon2PasswordHasher();
@@ -48,6 +46,7 @@ private static int PrintUsage()
{
Console.Error.WriteLine("Usage: TextCascade.Server user [options]");
Console.Error.WriteLine("Commands: add, passwd, disable, enable, delete, revoke-tokens, list, hash");
+ Console.Error.WriteLine("Password commands accept --password-stdin (reads one line from stdin).");
return Error;
}
@@ -59,8 +58,8 @@ private static int CommandAddUser(string[] args, IPasswordHasher hasher)
return Error;
}
- var password = ReadPassword("Password: ");
- var confirm = ReadPassword("Confirm: ");
+ var password = ReadPassword("Password: ", args);
+ var confirm = HasPasswordStdin(args) ? password : ReadPassword("Confirm: ", args);
if (!string.Equals(password, confirm, StringComparison.Ordinal))
{
Console.Error.WriteLine("Passwords do not match.");
@@ -109,7 +108,7 @@ private static int CommandPasswd(string[] args, IPasswordHasher hasher)
return Error;
}
- var password = ReadPassword("New password: ");
+ var password = ReadPassword("New password: ", args);
var hash = hasher.Hash(password, CreateArgon2Config(config));
users.Users[index] = users.Users[index] with { PasswordHash = hash };
UsersFile.SaveUsers(usersPath, users);
@@ -212,7 +211,7 @@ private static int CommandListUsers(string[] args)
private static int CommandHashPassword(string[] args, IPasswordHasher hasher)
{
- var password = ReadPassword("Password: ");
+ var password = ReadPassword("Password: ", args);
var config = Config.CreateDefaultConfig();
var hash = hasher.Hash(password, CreateArgon2Config(config));
Console.WriteLine(hash);
@@ -259,8 +258,28 @@ private static bool TryGetOption(string[] args, string name, out string value)
return false;
}
- private static string ReadPassword(string prompt)
+ internal static bool HasPasswordStdin(string[] args) => HasFlag(args, "password-stdin");
+
+ private static bool HasFlag(string[] args, string name)
+ {
+ var flag = $"--{name}";
+ return args.Any(arg => string.Equals(arg, flag, StringComparison.Ordinal));
+ }
+
+ internal static string ReadPassword(string prompt, string[] args)
{
+ if (HasPasswordStdin(args))
+ {
+ var line = Console.In.ReadLine();
+ if (string.IsNullOrEmpty(line))
+ {
+ Console.Error.WriteLine("--password-stdin requires one non-empty line.");
+ throw new ArgumentException("--password-stdin requires one non-empty line.");
+ }
+
+ return line;
+ }
+
Console.Write(prompt);
var builder = new StringBuilder();
while (true)
diff --git a/TextCascade.Server/Protocol.cs b/TextCascade.Server/Protocol.cs
index bb03e17..a46415d 100644
--- a/TextCascade.Server/Protocol.cs
+++ b/TextCascade.Server/Protocol.cs
@@ -110,7 +110,20 @@ public static LatestText From(ClipSnapshot snapshot, ulong version, string clien
[JsonSerializable(typeof(PingMessage))]
[JsonSerializable(typeof(ByeMessage))]
[JsonSerializable(typeof(ProtocolErrorMessage))]
-internal sealed partial class ServerJsonContext : JsonSerializerContext;
+[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
+internal sealed partial class ServerJsonContext : JsonSerializerContext
+{
+ public static ServerJsonContext Configured { get; }
+
+ public static JsonSerializerOptions SerializationOptions { get; } = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = false,
+ Converters = { new UtcSecondDateTimeConverter() },
+ };
+
+ static ServerJsonContext() => Configured = new ServerJsonContext(SerializationOptions);
+}
public sealed record WelcomeMessage(
[property: JsonPropertyName("type")] string Type,
@@ -148,6 +161,17 @@ public sealed record ProtocolErrorMessage(
[property: JsonPropertyName("message")] string Message,
[property: JsonPropertyName("referenceId")] string? ReferenceId);
+internal sealed class UtcSecondDateTimeConverter : JsonConverter
+{
+ public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => reader.TokenType == JsonTokenType.String && DateTimeOffset.TryParse(reader.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.None, out var value)
+ ? value.ToUniversalTime()
+ : throw new JsonException("Invalid date/time value.");
+
+ public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
+ => writer.WriteStringValue(value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture));
+}
+
public static class Protocol
{
public const int ProtocolVersion = 1;
@@ -158,19 +182,13 @@ public static class Protocol
public const int MaxHashBytes = 4096;
- private static readonly JsonSerializerOptions WriteOptions = new(JsonSerializerDefaults.Web)
- {
- DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
- WriteIndented = false,
- };
-
public static byte[] SerializeMessage(T message, JsonTypeInfo typeInfo) =>
JsonSerializer.SerializeToUtf8Bytes(message, typeInfo);
public static byte[] SerializeWelcome(LatestText? latest)
{
var message = new WelcomeMessage("welcome", ProtocolVersion, latest);
- return SerializeMessage(message, ServerJsonContext.Default.WelcomeMessage);
+ return SerializeMessage(message, ServerJsonContext.Configured.WelcomeMessage);
}
public static byte[] SerializeClip(string id, LatestText latest)
@@ -185,25 +203,25 @@ public static byte[] SerializeClip(string id, LatestText latest)
latest.FromClientId,
latest.FromClientName,
latest.UpdatedAtUtc);
- return SerializeMessage(message, ServerJsonContext.Default.ClipMessage);
+ return SerializeMessage(message, ServerJsonContext.Configured.ClipMessage);
}
public static byte[] SerializeClipAck(string id, LatestText latest)
{
var message = new ClipAckMessage("clip_ack", id, latest.Version, latest.UpdatedAtUtc);
- return SerializeMessage(message, ServerJsonContext.Default.ClipAckMessage);
+ return SerializeMessage(message, ServerJsonContext.Configured.ClipAckMessage);
}
public static byte[] SerializePing(DateTimeOffset nowUtc) =>
- SerializeMessage(new PingMessage("ping", nowUtc), ServerJsonContext.Default.PingMessage);
+ SerializeMessage(new PingMessage("ping", nowUtc), ServerJsonContext.Configured.PingMessage);
public static byte[] SerializeBye(string reason = "server_shutdown") =>
- SerializeMessage(new ByeMessage("bye", reason), ServerJsonContext.Default.ByeMessage);
+ SerializeMessage(new ByeMessage("bye", reason), ServerJsonContext.Configured.ByeMessage);
public static byte[] SerializeProtocolError(ProtocolError error) =>
SerializeMessage(
new ProtocolErrorMessage("error", error.CodeName, error.Message, error.ReferenceId),
- ServerJsonContext.Default.ProtocolErrorMessage);
+ ServerJsonContext.Configured.ProtocolErrorMessage);
public static byte[] SerializeLoginResponse(AuthToken token, bool needsRehash = false)
{
diff --git a/TextCascade.Server/SyncServer.cs b/TextCascade.Server/SyncServer.cs
index 486d7bb..2630dd1 100644
--- a/TextCascade.Server/SyncServer.cs
+++ b/TextCascade.Server/SyncServer.cs
@@ -37,6 +37,7 @@ public sealed class ConnectionStateBag
private DateTimeOffset lastPingAt;
private bool closed;
private bool helloTimeoutStarted;
+ private bool pongAwaited;
public Channel SendQueue { get; }
public CancellationTokenSource Cts { get; }
public bool HelloReceived { get; internal set; }
@@ -54,6 +55,21 @@ public DateTimeOffset LastPingAt
internal set { lock (gate) { lastPingAt = value; } }
}
+ public void MarkPingAwaitingPong()
+ {
+ lock (gate) { pongAwaited = true; }
+ }
+
+ public bool TryTakePongAwaiting()
+ {
+ lock (gate)
+ {
+ if (!pongAwaited) return false;
+ pongAwaited = false;
+ return true;
+ }
+ }
+
public bool IsClosed
{
get { lock (gate) { return closed; } }
@@ -366,6 +382,7 @@ public void EnqueuePing(DateTimeOffset nowUtc)
}
connection.State.LastPingAt = nowUtc;
+ connection.State.MarkPingAwaitingPong();
if (!connection.State.TryEnqueueSend(bytes) && connection.State.MarkClosed())
{
connection.State.Cts.Cancel();
@@ -687,6 +704,10 @@ public static async Task RunAsync(ConnectionContext provisional, TokenPayload pa
var received = await ReceiveFrameAsync(provisional, config.Limits.MaxFrameBytes, provisional.State.Cts.Token);
if (received.MessageType == WebSocketMessageType.Close)
{
+ await provisional.Socket.CloseOutputAsync(
+ WebSocketCloseStatus.NormalClosure,
+ "client_closed",
+ CancellationToken.None);
SyncServer.Instance.CancelConnection(provisional, "closed");
return;
}
@@ -851,7 +872,18 @@ private static async Task ReadLoopAsync(ConnectionContext connection, RuntimeCon
break;
}
- if (received.MessageType == WebSocketMessageType.Close) break;
+ if (received.MessageType == WebSocketMessageType.Close)
+ {
+ try
+ {
+ await connection.Socket.CloseOutputAsync(
+ WebSocketCloseStatus.NormalClosure,
+ "client_closed",
+ CancellationToken.None);
+ }
+ catch (WebSocketException) { }
+ break;
+ }
if (!Protocol.CheckFrameSize(received.Payload.Length, config))
{
@@ -897,6 +929,16 @@ private static async Task ReadLoopAsync(ConnectionContext connection, RuntimeCon
}
break;
case MessageKind.Pong:
+ if (!connection.State.TryTakePongAwaiting())
+ {
+ var unsolicitedPong = Protocol.SerializeProtocolError(new ProtocolError(
+ ProtocolErrorCode.InvalidMessage,
+ "Pong received without an outstanding ping.",
+ null));
+ await SendSafeAsync(connection, unsolicitedPong);
+ continue;
+ }
+
if (connection.Hub is null || !connection.Hub.TryWriteJob(new PongJob(connection, (ClientPong)parse.Message!)))
{
SyncServer.Instance.CancelConnection(connection, "user_loop_unavailable");
From 701828c1694f3633a02f4d4e8b795094cdede5ce Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Tue, 18 Aug 2026 23:48:36 +0800
Subject: [PATCH 04/32] Bump version to 0.2.1
---
TextCascade.Server/TextCascade.Server.csproj | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/TextCascade.Server/TextCascade.Server.csproj b/TextCascade.Server/TextCascade.Server.csproj
index a1b9775..db51706 100644
--- a/TextCascade.Server/TextCascade.Server.csproj
+++ b/TextCascade.Server/TextCascade.Server.csproj
@@ -4,7 +4,7 @@
net10.0
enable
enable
- 0.2.0
+ 0.2.1
TextCascade.Server
true
From 248a63538d36a9b3003032f503b18e3458a66b64 Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Sat, 22 Aug 2026 00:57:48 +0800
Subject: [PATCH 05/32] Release v0.2.5
---
README.md | 14 +-
TextCascade.Server.Tests/ClipAndCoreTests.cs | 12 +
.../ProtocolParseTests.cs | 6 +-
.../ProtocolSerializationTests.cs | 2 +-
.../RuntimeStateAndProtocolTests.cs | 140 +++++++
TextCascade.Server/AuthService.cs | 14 +-
TextCascade.Server/Cli.cs | 91 +++--
TextCascade.Server/Core.cs | 23 ++
TextCascade.Server/Protocol.cs | 13 +-
TextCascade.Server/RuntimeConfig.cs | 34 +-
TextCascade.Server/RuntimeStateStore.cs | 129 +++++++
TextCascade.Server/ServerHost.cs | 47 ++-
TextCascade.Server/SyncServer.cs | 347 ++++++++++++------
TextCascade.Server/TextCascade.Server.csproj | 2 +-
deploy/textcascade-server.service | 8 +-
deploy/textcascade.toml | 1 +
16 files changed, 685 insertions(+), 198 deletions(-)
create mode 100644 TextCascade.Server.Tests/RuntimeStateAndProtocolTests.cs
create mode 100644 TextCascade.Server/RuntimeStateStore.cs
diff --git a/README.md b/README.md
index 7029e05..d90d8f4 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,7 @@ TextCascade-Server/
3. **添加用户**
```
- dotnet TextCascade.Server.dll user add --username alice
+ dotnet TextCascade.Server.dll user add --config /etc/textcascade/textcascade.toml --username alice
```
CLI 子命令:`add`、`passwd`、`disable`、`enable`、`delete`、`revoke-tokens`、`list`、`hash`。
@@ -115,6 +115,7 @@ users_file = "users.json"
关键规则:
- `token_secret_env` 指向环境变量名,secret 不写入 TOML;长度 < 32 字节则启动失败。
+- CLI 配置回退顺序为 `--config`、`TEXTCASCADE_CONFIG`、当前目录 `textcascade.toml`;`TEXTCASCADE_USERS_FILE` 与 `TEXTCASCADE_STATE_FILE` 仍可覆盖 TOML。
- TLS 始终启用;证书仅支持无密码格式(PEM bundle 或无密码 PFX),带密码 PFX 不支持。
- `max_frame_bytes` 必须大于 `max_text_bytes`(差额留给协议头)。
- 所有容量与时间配置必须 > 0,心跳超时必须大于心跳间隔。
@@ -158,8 +159,9 @@ GitHub Release 提供两种 Framework-dependent 单文件包,目标机需预装
### 生产部署(systemd)
参考 `deploy/textcascade-server.service`:
+- 以专用系统用户 `textcascade` 运行;先执行 `useradd --system --home /opt/textcascade-server --shell /usr/sbin/nologin textcascade`。
- 以 systemd 托管,`Restart=on-failure`,开启 `ProtectSystem`/`ProtectHome`/`PrivateTmp` 等加固项。
-- 配置与 users.json 放 `/etc/textcascade/`,程序放 `/opt/textcascade-server/`。
+- 配置与 users.json 放 `/etc/textcascade/`,运行状态放 `/var/lib/textcascade/`,程序放 `/opt/textcascade-server/`;目录属主设为 `textcascade:textcascade`。
- token secret 由 `/etc/textcascade/textcascade.env` 注入。
### 许可
@@ -210,7 +212,7 @@ Built on ASP.NET Core Minimal API with native Kestrel WebSockets, TLS-terminated
3. **Add a user**
```
- dotnet TextCascade.Server.dll user add --username alice
+ dotnet TextCascade.Server.dll user add --config /etc/textcascade/textcascade.toml --username alice
```
CLI subcommands: `add`, `passwd`, `disable`, `enable`, `delete`, `revoke-tokens`, `list`, `hash`.
@@ -257,10 +259,13 @@ clip_tokens_per_second = 2
[files]
users_file = "users.json"
+state_file = "textcascade.state.json"
+state_file = "textcascade.state.json"
```
Key rules:
- `token_secret_env` names an env var; the secret is never written to TOML and must be >= 32 bytes.
+- CLI config fallback is `--config`, then `TEXTCASCADE_CONFIG`, then `textcascade.toml`; `TEXTCASCADE_USERS_FILE` and `TEXTCASCADE_STATE_FILE` still override TOML.
- TLS is always on; only password-less certs are supported (PEM bundle or password-less PFX).
- `max_frame_bytes` must exceed `max_text_bytes` (the difference covers the JSON header).
- All capacity/time values must be > 0; heartbeat timeout must exceed the interval.
@@ -304,8 +309,9 @@ Pushing a `v*.*.*` tag (for example `v0.2.0`) runs tests, builds both single-fil
### Production (systemd)
See `deploy/textcascade-server.service`:
+- Run as the dedicated `textcascade` system user; create it with `useradd --system --home /opt/textcascade-server --shell /usr/sbin/nologin textcascade`.
- Managed by systemd with `Restart=on-failure` and hardening flags (`ProtectSystem`, `ProtectHome`, `PrivateTmp`).
-- Config and `users.json` under `/etc/textcascade/`; binaries under `/opt/textcascade-server/`.
+- Config and `users.json` under `/etc/textcascade/`, runtime state under `/var/lib/textcascade/`, and binaries under `/opt/textcascade-server/`; set directory ownership to `textcascade:textcascade`.
- Token secret injected via `/etc/textcascade/textcascade.env`.
### License
diff --git a/TextCascade.Server.Tests/ClipAndCoreTests.cs b/TextCascade.Server.Tests/ClipAndCoreTests.cs
index 50c3501..6184282 100644
--- a/TextCascade.Server.Tests/ClipAndCoreTests.cs
+++ b/TextCascade.Server.Tests/ClipAndCoreTests.cs
@@ -47,6 +47,18 @@ public void SeenIdRingRetainsOriginalResultForDuplicateAck()
Assert.Equal(original, result);
}
+ [Fact]
+ public void SeenIdRingTreatsSameIdWithChangedContentAsNewClip()
+ {
+ var ring = new SeenIdRing(4);
+ var original = new LatestText("first", 1, "hash-1", false, "client", "name", DateTimeOffset.UtcNow);
+ ring.RememberId("same-id", original);
+
+ Assert.False(ring.IsUnchangedDuplicate("same-id", "second", "hash-2", false, out _));
+ Assert.True(ring.IsUnchangedDuplicate("same-id", "first", "hash-1", false, out var unchanged));
+ Assert.Equal(original, unchanged);
+ }
+
[Fact]
public void TokenBucketRefillsOverTime()
{
diff --git a/TextCascade.Server.Tests/ProtocolParseTests.cs b/TextCascade.Server.Tests/ProtocolParseTests.cs
index b7021d0..d4f3b72 100644
--- a/TextCascade.Server.Tests/ProtocolParseTests.cs
+++ b/TextCascade.Server.Tests/ProtocolParseTests.cs
@@ -5,6 +5,8 @@ namespace TextCascade.Server.Tests;
public class ProtocolParseTests
{
+ private const string ValidHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+
private static RuntimeConfig NewConfig() => TextCascade.Server.Config.CreateDefaultConfig();
private static ParseResult Parse(string json) =>
@@ -13,7 +15,7 @@ private static ParseResult Parse(string json) =>
[Fact]
public void ParsesHello()
{
- var result = Parse("{\"type\":\"hello\",\"clientId\":\"a\",\"clientName\":\"n\",\"lastServerVersion\":5,\"snapshot\":{\"payload\":\"p\",\"encrypted\":true,\"hash\":\"h\",\"localModifiedAtUtc\":\"2026-08-18T08:00:00Z\"}}");
+ var result = Parse($"{{\"type\":\"hello\",\"clientId\":\"a\",\"clientName\":\"n\",\"lastServerVersion\":5,\"snapshot\":{{\"payload\":\"p\",\"encrypted\":true,\"hash\":\"{ValidHash}\",\"localModifiedAtUtc\":\"2026-08-18T08:00:00Z\"}}}}");
Assert.True(result.IsSuccess);
Assert.Equal(MessageKind.Hello, result.Kind);
@@ -26,7 +28,7 @@ public void ParsesHello()
[Fact]
public void ParsesClip()
{
- var result = Parse("{\"type\":\"clip\",\"id\":\"id1\",\"payload\":\"p\",\"encrypted\":true,\"hash\":\"h\"}");
+ var result = Parse($"{{\"type\":\"clip\",\"id\":\"id1\",\"payload\":\"p\",\"encrypted\":true,\"hash\":\"{ValidHash}\"}}");
Assert.True(result.IsSuccess);
Assert.Equal(MessageKind.Clip, result.Kind);
diff --git a/TextCascade.Server.Tests/ProtocolSerializationTests.cs b/TextCascade.Server.Tests/ProtocolSerializationTests.cs
index f7e3220..7823101 100644
--- a/TextCascade.Server.Tests/ProtocolSerializationTests.cs
+++ b/TextCascade.Server.Tests/ProtocolSerializationTests.cs
@@ -12,7 +12,7 @@ public void WelcomeLatestTimeUsesUtcSecondFormat()
var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(1760000000123).ToUniversalTime();
var latest = new LatestText("payload", 7, "hash", true, "client", "name", timestamp);
- var json = Encoding.UTF8.GetString(Protocol.SerializeWelcome(latest));
+ var json = Encoding.UTF8.GetString(Protocol.SerializeWelcome(latest, TextCascade.Server.Config.CreateDefaultConfig().Limits));
using var document = JsonDocument.Parse(json);
var actual = document.RootElement.GetProperty("latest").GetProperty("updatedAtUtc").GetString();
diff --git a/TextCascade.Server.Tests/RuntimeStateAndProtocolTests.cs b/TextCascade.Server.Tests/RuntimeStateAndProtocolTests.cs
new file mode 100644
index 0000000..b89fa76
--- /dev/null
+++ b/TextCascade.Server.Tests/RuntimeStateAndProtocolTests.cs
@@ -0,0 +1,140 @@
+using System.Text;
+using Microsoft.Extensions.Logging.Abstractions;
+using TextCascade.Server;
+
+namespace TextCascade.Server.Tests;
+
+public class RuntimeStateAndProtocolTests
+{
+ private static readonly DateTimeOffset TestStartTime = DateTimeOffset.FromUnixTimeSeconds(1760000000);
+
+ [Fact]
+ public void StateStorePersistsHighestVersionAtomically()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"textcascade-state-{Guid.NewGuid():N}.json");
+ try
+ {
+ var first = new RuntimeStateStore(path);
+ first.SaveVersion("alice", 7UL);
+
+ var second = new RuntimeStateStore(path);
+ second.SaveVersion("alice", 5UL);
+
+ Assert.Equal(7UL, second.GetVersion("alice"));
+ Assert.Equal(7UL, new RuntimeStateStore(path).GetVersion("alice"));
+ }
+ finally
+ {
+ if (File.Exists(path)) File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void StateStoreRejectsInvalidFile()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"textcascade-state-{Guid.NewGuid():N}.json");
+ try
+ {
+ File.WriteAllText(path, """{"entries":[{"username":"alice","version":0}]}""", Encoding.UTF8);
+ Assert.Throws(() => new RuntimeStateStore(path));
+ }
+ finally
+ {
+ if (File.Exists(path)) File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void ClipValidationAcceptsClientLocalHash()
+ {
+ var config = TextCascade.Server.Config.CreateDefaultConfig();
+ Assert.True(Protocol.ValidateClipMessage(new ClientClip("id", "payload", true, new string('a', 64)), config));
+ Assert.True(Protocol.ValidateClipMessage(new ClientClip("id", "payload", true, "client-local-hash"), config));
+ Assert.False(Protocol.ValidateClipMessage(new ClientClip("id", "payload", true, ""), config));
+ Assert.False(Protocol.ValidateClipMessage(new ClientClip("id", "payload", true, new string('a', 4097)), config));
+ }
+
+ [Fact]
+ public void ConfigParsesStateFilePath()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"textcascade-config-{Guid.NewGuid():N}.toml");
+ try
+ {
+ File.WriteAllText(path, "[files]\nstate_file = \"/tmp/state.json\"\n");
+ var config = TextCascade.Server.Config.LoadTomlConfig(path);
+ Assert.Equal("/tmp/state.json", config.Files.StateFile);
+ }
+ finally
+ {
+ if (File.Exists(path)) File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void RecoveryWindowRestoresSnapshotAtPersistedVersion()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"textcascade-state-{Guid.NewGuid():N}.json");
+ try
+ {
+ new RuntimeStateStore(path).SaveVersion("alice", 7UL);
+ var config = TextCascade.Server.Config.CreateDefaultConfig();
+ var server = new SyncServer(
+ config,
+ new UsersFile(),
+ new RuntimeStateStore(path),
+ new Argon2PasswordHasher(),
+ new SystemClock(),
+ NullLogger.Instance);
+ var hub = new UserHub("alice", config, TestStartTime, server, 7UL);
+ var modified = DateTimeOffset.FromUnixTimeSeconds(1759999990);
+ hub.AcceptSnapshot(new ClientHello(
+ "client-a",
+ "Client A",
+ 7UL,
+ new ClipSnapshot("restored", false, "client-local-hash", modified)));
+
+ hub.CloseRecoveryWindow(TestStartTime.AddSeconds(3));
+
+ Assert.NotNull(hub.Latest);
+ Assert.Equal(7UL, hub.Version);
+ Assert.Equal("restored", hub.Latest!.Payload);
+ }
+ finally
+ {
+ if (File.Exists(path)) File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void RecoveryWindowIgnoresStaleSnapshot()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"textcascade-state-{Guid.NewGuid():N}.json");
+ try
+ {
+ new RuntimeStateStore(path).SaveVersion("alice", 7UL);
+ var config = TextCascade.Server.Config.CreateDefaultConfig();
+ var server = new SyncServer(
+ config,
+ new UsersFile(),
+ new RuntimeStateStore(path),
+ new Argon2PasswordHasher(),
+ new SystemClock(),
+ NullLogger.Instance);
+ var hub = new UserHub("alice", config, TestStartTime, server, 7UL);
+ hub.AcceptSnapshot(new ClientHello(
+ "client-a",
+ "Client A",
+ 6UL,
+ new ClipSnapshot("stale", false, "client-local-hash", TestStartTime)));
+
+ hub.CloseRecoveryWindow(TestStartTime.AddSeconds(3));
+
+ Assert.Null(hub.Latest);
+ Assert.Equal(7UL, hub.Version);
+ }
+ finally
+ {
+ if (File.Exists(path)) File.Delete(path);
+ }
+ }
+}
diff --git a/TextCascade.Server/AuthService.cs b/TextCascade.Server/AuthService.cs
index 434006b..d1688b8 100644
--- a/TextCascade.Server/AuthService.cs
+++ b/TextCascade.Server/AuthService.cs
@@ -7,10 +7,10 @@ namespace TextCascade.Server;
public sealed class AuthService
{
- public static async Task HandleLoginAsync(HttpContext context, RuntimeConfig config, ILogger? logger = null)
+ public static async Task HandleLoginAsync(HttpContext context, RuntimeConfig config, SyncServer syncServer, ILogger? logger = null)
{
- var limiter = SyncServer.Instance.LoginLimiter;
- var clock = SyncServer.Instance.Clock;
+ var limiter = syncServer.LoginLimiter;
+ var clock = syncServer.Clock;
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var now = clock.UtcNow;
@@ -32,9 +32,9 @@ public static async Task HandleLoginAsync(HttpContext context, RuntimeConfig con
return;
}
- var userLookup = SyncServer.Instance.UserLookup;
+ var userLookup = syncServer.UserLookup;
var found = userLookup.TryGetValue(request.Username, out var user);
- var passwordOk = found && user is not null && SyncServer.Instance.Hasher.Verify(request.Password, user.PasswordHash);
+ var passwordOk = found && user is not null && syncServer.Hasher.Verify(request.Password, user.PasswordHash);
if (!passwordOk)
{
logger?.LogSecurityEvent("login", ("username", request.Username), ("ip", ip), ("success", false), ("reason", "invalid_credentials"));
@@ -55,7 +55,7 @@ public static async Task HandleLoginAsync(HttpContext context, RuntimeConfig con
// Spec §4.1: on parameter drift, emit a structured rehash warning rather than rewriting users.json.
bool needsRehash = false;
- if (SyncServer.Instance.Hasher.NeedsRehash(authenticatedUser.PasswordHash, Cli.CreateArgon2Config(config)))
+ if (syncServer.Hasher.NeedsRehash(authenticatedUser.PasswordHash, Cli.CreateArgon2Config(config)))
{
needsRehash = true;
logger?.LogWarning("Argon2 password hash needs rehash for user {Username}; users.json was not rewritten.", authenticatedUser.Username);
@@ -63,7 +63,7 @@ public static async Task HandleLoginAsync(HttpContext context, RuntimeConfig con
var tokenService = new TokenService(config.TokenSecret!);
var token = tokenService.CreateToken(authenticatedUser, now, TimeSpan.FromDays(config.Auth.TokenTtlDays));
- var bytes = Protocol.SerializeLoginResponse(token, needsRehash);
+ var bytes = Protocol.SerializeLoginResponse(token, config, needsRehash);
context.Response.StatusCode = 200;
context.Response.ContentType = "application/json";
await context.Response.BodyWriter.WriteAsync(bytes);
diff --git a/TextCascade.Server/Cli.cs b/TextCascade.Server/Cli.cs
index 2e45a54..6c52614 100644
--- a/TextCascade.Server/Cli.cs
+++ b/TextCascade.Server/Cli.cs
@@ -3,6 +3,7 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
+using System.Text.Json;
using Isopoh.Cryptography.Argon2;
namespace TextCascade.Server;
@@ -28,16 +29,38 @@ public static int RunCli(string[] args, IPasswordHasher? hasher = null)
}
var rest = args.Skip(1).ToArray();
+ if (!TryExtractConfigOption(ref rest, out var configPath))
+ {
+ Console.Error.WriteLine("--config requires a path.");
+ return Error;
+ }
+
+ configPath ??= Environment.GetEnvironmentVariable("TEXTCASCADE_CONFIG") is { Length: > 0 } environmentConfig
+ ? environmentConfig
+ : "textcascade.toml";
+ RuntimeConfig config;
+ try
+ {
+ config = Config.CreateDefaultConfig();
+ config = Config.LoadTomlConfig(configPath, config);
+ config = Config.ApplyEnvironmentOverrides(config);
+ }
+ catch (Exception exception) when (exception is InvalidOperationException or JsonException or DecoderFallbackException or IOException)
+ {
+ Console.Error.WriteLine($"Configuration error: {exception.Message}");
+ return Error;
+ }
+
return rest switch
{
- { Length: > 0 } when rest[0] == "add" => CommandAddUser(rest, hasher),
- { Length: > 0 } when rest[0] == "passwd" => CommandPasswd(rest, hasher),
- { Length: > 0 } when rest[0] == "disable" => CommandSetDisabled(rest, disabled: true),
- { Length: > 0 } when rest[0] == "enable" => CommandSetDisabled(rest, disabled: false),
- { Length: > 0 } when rest[0] == "delete" => CommandDeleteUser(rest),
- { Length: > 0 } when rest[0] == "revoke-tokens" => CommandRevokeTokens(rest),
- { Length: > 0 } when rest[0] == "list" => CommandListUsers(rest),
- { Length: > 0 } when rest[0] == "hash" => CommandHashPassword(rest, hasher),
+ { Length: > 0 } when rest[0] == "add" => CommandAddUser(rest, hasher, config),
+ { Length: > 0 } when rest[0] == "passwd" => CommandPasswd(rest, hasher, config),
+ { Length: > 0 } when rest[0] == "disable" => CommandSetDisabled(rest, disabled: true, config),
+ { Length: > 0 } when rest[0] == "enable" => CommandSetDisabled(rest, disabled: false, config),
+ { Length: > 0 } when rest[0] == "delete" => CommandDeleteUser(rest, config),
+ { Length: > 0 } when rest[0] == "revoke-tokens" => CommandRevokeTokens(rest, config),
+ { Length: > 0 } when rest[0] == "list" => CommandListUsers(rest, config),
+ { Length: > 0 } when rest[0] == "hash" => CommandHashPassword(rest, hasher, config),
_ => PrintUsage(),
};
}
@@ -46,11 +69,40 @@ private static int PrintUsage()
{
Console.Error.WriteLine("Usage: TextCascade.Server user [options]");
Console.Error.WriteLine("Commands: add, passwd, disable, enable, delete, revoke-tokens, list, hash");
- Console.Error.WriteLine("Password commands accept --password-stdin (reads one line from stdin).");
+ Console.Error.WriteLine("All commands accept --config ; fallback order is --config, TEXTCASCADE_CONFIG, then textcascade.toml.");
return Error;
}
- private static int CommandAddUser(string[] args, IPasswordHasher hasher)
+ private static bool TryExtractConfigOption(ref string[] args, out string? configPath)
+ {
+ configPath = null;
+ var remaining = new List();
+ for (var index = 0; index < args.Length; index++)
+ {
+ if (string.Equals(args[index], "--config", StringComparison.Ordinal))
+ {
+ if (index + 1 >= args.Length)
+ {
+ return false;
+ }
+
+ configPath = args[++index];
+ }
+ else if (args[index].StartsWith("--config=", StringComparison.Ordinal))
+ {
+ configPath = args[index]["--config=".Length..];
+ }
+ else
+ {
+ remaining.Add(args[index]);
+ }
+ }
+
+ args = remaining.ToArray();
+ return true;
+ }
+
+ private static int CommandAddUser(string[] args, IPasswordHasher hasher, RuntimeConfig config)
{
if (!TryGetOption(args, "username", out var username))
{
@@ -66,7 +118,6 @@ private static int CommandAddUser(string[] args, IPasswordHasher hasher)
return Error;
}
- var config = Config.CreateDefaultConfig();
var usersPath = config.Files.UsersFile;
var users = LoadForWrite(usersPath);
if (users.Users.Any(user => string.Equals(user.Username, username, StringComparison.Ordinal)))
@@ -90,7 +141,7 @@ private static int CommandAddUser(string[] args, IPasswordHasher hasher)
return Ok;
}
- private static int CommandPasswd(string[] args, IPasswordHasher hasher)
+ private static int CommandPasswd(string[] args, IPasswordHasher hasher, RuntimeConfig config)
{
if (!TryGetOption(args, "username", out var username))
{
@@ -98,7 +149,6 @@ private static int CommandPasswd(string[] args, IPasswordHasher hasher)
return Error;
}
- var config = Config.CreateDefaultConfig();
var usersPath = config.Files.UsersFile;
var users = LoadForWrite(usersPath);
var index = users.Users.FindIndex(user => string.Equals(user.Username, username, StringComparison.Ordinal));
@@ -116,7 +166,7 @@ private static int CommandPasswd(string[] args, IPasswordHasher hasher)
return Ok;
}
- private static int CommandSetDisabled(string[] args, bool disabled)
+ private static int CommandSetDisabled(string[] args, bool disabled, RuntimeConfig config)
{
if (!TryGetOption(args, "username", out var username))
{
@@ -124,7 +174,6 @@ private static int CommandSetDisabled(string[] args, bool disabled)
return Error;
}
- var config = Config.CreateDefaultConfig();
var usersPath = config.Files.UsersFile;
var users = LoadForWrite(usersPath);
var index = users.Users.FindIndex(user => string.Equals(user.Username, username, StringComparison.Ordinal));
@@ -140,7 +189,7 @@ private static int CommandSetDisabled(string[] args, bool disabled)
return Ok;
}
- private static int CommandDeleteUser(string[] args)
+ private static int CommandDeleteUser(string[] args, RuntimeConfig config)
{
if (!TryGetOption(args, "username", out var username))
{
@@ -148,7 +197,6 @@ private static int CommandDeleteUser(string[] args)
return Error;
}
- var config = Config.CreateDefaultConfig();
var usersPath = config.Files.UsersFile;
var users = LoadForWrite(usersPath);
var index = users.Users.FindIndex(user => string.Equals(user.Username, username, StringComparison.Ordinal));
@@ -164,7 +212,7 @@ private static int CommandDeleteUser(string[] args)
return Ok;
}
- private static int CommandRevokeTokens(string[] args)
+ private static int CommandRevokeTokens(string[] args, RuntimeConfig config)
{
if (!TryGetOption(args, "username", out var username))
{
@@ -172,7 +220,6 @@ private static int CommandRevokeTokens(string[] args)
return Error;
}
- var config = Config.CreateDefaultConfig();
var usersPath = config.Files.UsersFile;
var users = LoadForWrite(usersPath);
var index = users.Users.FindIndex(user => string.Equals(user.Username, username, StringComparison.Ordinal));
@@ -196,9 +243,8 @@ private static int CommandRevokeTokens(string[] args)
return Ok;
}
- private static int CommandListUsers(string[] args)
+ private static int CommandListUsers(string[] args, RuntimeConfig config)
{
- var config = Config.CreateDefaultConfig();
var users = File.Exists(config.Files.UsersFile) ? UsersFile.LoadUsers(config.Files.UsersFile) : new UsersFile();
Console.WriteLine($"nextTokenVersion: {users.NextTokenVersion}");
Console.WriteLine("username,disabled,tokenVersion");
@@ -209,10 +255,9 @@ private static int CommandListUsers(string[] args)
return Ok;
}
- private static int CommandHashPassword(string[] args, IPasswordHasher hasher)
+ private static int CommandHashPassword(string[] args, IPasswordHasher hasher, RuntimeConfig config)
{
var password = ReadPassword("Password: ", args);
- var config = Config.CreateDefaultConfig();
var hash = hasher.Hash(password, CreateArgon2Config(config));
Console.WriteLine(hash);
return Ok;
diff --git a/TextCascade.Server/Core.cs b/TextCascade.Server/Core.cs
index 057f3a3..ff61597 100644
--- a/TextCascade.Server/Core.cs
+++ b/TextCascade.Server/Core.cs
@@ -178,6 +178,29 @@ public void RememberId(string id, LatestText? result)
}
}
+ public bool IsUnchangedDuplicate(string id, string payload, string hash, bool encrypted, out LatestText? latest)
+ {
+ lock (gate)
+ {
+ for (var index = 0; index < ids.Length; index++)
+ {
+ if (!string.Equals(ids[index], id, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ latest = results[index];
+ return latest is not null
+ && string.Equals(latest.Payload, payload, StringComparison.Ordinal)
+ && string.Equals(latest.Hash, hash, StringComparison.Ordinal)
+ && latest.Encrypted == encrypted;
+ }
+
+ latest = null;
+ return false;
+ }
+ }
+
private void RememberInternal(string id, LatestText? result)
{
ids[next] = id;
diff --git a/TextCascade.Server/Protocol.cs b/TextCascade.Server/Protocol.cs
index a46415d..83360b4 100644
--- a/TextCascade.Server/Protocol.cs
+++ b/TextCascade.Server/Protocol.cs
@@ -185,7 +185,7 @@ public static class Protocol
public static byte[] SerializeMessage(T message, JsonTypeInfo typeInfo) =>
JsonSerializer.SerializeToUtf8Bytes(message, typeInfo);
- public static byte[] SerializeWelcome(LatestText? latest)
+ public static byte[] SerializeWelcome(LatestText? latest, LimitsConfig _)
{
var message = new WelcomeMessage("welcome", ProtocolVersion, latest);
return SerializeMessage(message, ServerJsonContext.Configured.WelcomeMessage);
@@ -223,7 +223,7 @@ public static byte[] SerializeProtocolError(ProtocolError error) =>
new ProtocolErrorMessage("error", error.CodeName, error.Message, error.ReferenceId),
ServerJsonContext.Configured.ProtocolErrorMessage);
- public static byte[] SerializeLoginResponse(AuthToken token, bool needsRehash = false)
+ public static byte[] SerializeLoginResponse(AuthToken token, RuntimeConfig config, bool needsRehash = false)
{
using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream);
@@ -231,10 +231,10 @@ public static byte[] SerializeLoginResponse(AuthToken token, bool needsRehash =
writer.WriteString("token", token.CompactToken);
writer.WriteString("expiresAtUtc", DateTimeOffset.FromUnixTimeSeconds(token.Payload.ExpiresAtUnix).ToUniversalTime().ToString("O"));
writer.WriteNumber("protocolVersion", ProtocolVersion);
- writer.WriteNumber("maxTextBytes", RuntimeConfigAccessor.Current?.Limits.MaxTextBytes ?? 524288);
- writer.WriteNumber("helloTimeoutSeconds", RuntimeConfigAccessor.Current?.Limits.HelloTimeoutSeconds ?? 5);
- writer.WriteNumber("heartbeatIntervalSeconds", RuntimeConfigAccessor.Current?.Limits.HeartbeatIntervalSeconds ?? 30);
- writer.WriteNumber("heartbeatTimeoutSeconds", RuntimeConfigAccessor.Current?.Limits.HeartbeatTimeoutSeconds ?? 60);
+ writer.WriteNumber("maxTextBytes", config.Limits.MaxTextBytes);
+ writer.WriteNumber("helloTimeoutSeconds", config.Limits.HelloTimeoutSeconds);
+ writer.WriteNumber("heartbeatIntervalSeconds", config.Limits.HeartbeatIntervalSeconds);
+ writer.WriteNumber("heartbeatTimeoutSeconds", config.Limits.HeartbeatTimeoutSeconds);
if (needsRehash)
{
writer.WriteBoolean("needsRehash", true);
@@ -422,7 +422,6 @@ private static bool ValidateClipSnapshot(ClipSnapshot snapshot, RuntimeConfig co
{
return snapshot.Payload.Length > 0
&& Encoding.UTF8.GetByteCount(snapshot.Payload) <= config.Limits.MaxTextBytes
- && snapshot.Hash.Length > 0
&& Encoding.UTF8.GetByteCount(snapshot.Hash) <= MaxHashBytes
&& snapshot.LocalModifiedAtUtc.Offset == TimeSpan.Zero;
}
diff --git a/TextCascade.Server/RuntimeConfig.cs b/TextCascade.Server/RuntimeConfig.cs
index a182d28..9071a24 100644
--- a/TextCascade.Server/RuntimeConfig.cs
+++ b/TextCascade.Server/RuntimeConfig.cs
@@ -36,7 +36,7 @@ public sealed record RateLimitConfig(
int ClipBurst,
int ClipTokensPerSecond);
-public sealed record FilesConfig(string UsersFile);
+public sealed record FilesConfig(string UsersFile, string StateFile);
public sealed record RuntimeConfig(
ServerConfig Server,
@@ -46,17 +46,6 @@ public sealed record RuntimeConfig(
FilesConfig Files,
byte[]? TokenSecret = null);
-public static class RuntimeConfigAccessor
-{
- private static RuntimeConfig? current;
-
- public static RuntimeConfig? Current
- {
- get => current;
- set => current = value;
- }
-}
-
public static class Config
{
public static RuntimeConfig CreateDefaultConfig() => new(
@@ -64,7 +53,7 @@ public static class Config
new AuthConfig(30, "TEXTCASCADE_TOKEN_SECRET", 19456, 2, 1),
new LimitsConfig(524288, 589824, 16, 64, 5, 30, 60, 3, 4194304, 16),
new RateLimitConfig(10, 5, 10000, 10, 2),
- new FilesConfig("users.json"));
+ new FilesConfig("users.json", "textcascade.state.json"));
public static RuntimeConfig LoadTomlConfig(string path, RuntimeConfig? defaults = null)
{
@@ -158,8 +147,13 @@ private static RuntimeConfig ApplyTomlModel(RuntimeConfig config, TomlTable mode
if (TryGetTable(model, "files", out var files))
{
- WarnUnknownKeys(files, "files", new[] { "users_file" });
- config = config with { Files = new FilesConfig(GetString(files, "users_file", config.Files.UsersFile, "files.users_file")) };
+ WarnUnknownKeys(files, "files", new[] { "users_file", "state_file" });
+ config = config with
+ {
+ Files = new FilesConfig(
+ GetString(files, "users_file", config.Files.UsersFile, "files.users_file"),
+ GetString(files, "state_file", config.Files.StateFile, "files.state_file")),
+ };
}
return config;
@@ -239,6 +233,11 @@ public static RuntimeConfig ApplyEnvironmentOverrides(RuntimeConfig config)
config = config with { Files = config.Files with { UsersFile = usersFile } };
}
+ if (Environment.GetEnvironmentVariable("TEXTCASCADE_STATE_FILE") is { Length: > 0 } stateFile)
+ {
+ config = config with { Files = config.Files with { StateFile = stateFile } };
+ }
+
if (Environment.GetEnvironmentVariable(config.Auth.TokenSecretEnv) is { Length: > 0 } secretText)
{
var secret = Encoding.UTF8.GetBytes(secretText);
@@ -315,5 +314,10 @@ public static void ValidateConfig(RuntimeConfig config)
{
throw new InvalidOperationException("files.users_file must not be empty.");
}
+
+ if (string.IsNullOrWhiteSpace(config.Files.StateFile))
+ {
+ throw new InvalidOperationException("files.state_file must not be empty.");
+ }
}
}
diff --git a/TextCascade.Server/RuntimeStateStore.cs b/TextCascade.Server/RuntimeStateStore.cs
new file mode 100644
index 0000000..2288ba1
--- /dev/null
+++ b/TextCascade.Server/RuntimeStateStore.cs
@@ -0,0 +1,129 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace TextCascade.Server;
+
+public sealed record RuntimeStateEntry(string Username, ulong Version);
+
+internal sealed record RuntimeStateFile(IReadOnlyList Entries);
+
+public sealed class RuntimeStateStore
+{
+ private readonly object gate = new();
+ private readonly string path;
+ private readonly Dictionary versions;
+
+ public RuntimeStateStore(string path)
+ {
+ this.path = path;
+ versions = Load(path);
+ }
+
+ public ulong GetVersion(string username)
+ {
+ lock (gate)
+ {
+ return versions.TryGetValue(username, out var version) ? version : 0UL;
+ }
+ }
+
+ public void SaveVersion(string username, ulong version)
+ {
+ lock (gate)
+ {
+ if (versions.TryGetValue(username, out var current) && version <= current)
+ {
+ return;
+ }
+
+ versions[username] = version;
+ WriteAtomic(path, versions.Select(pair => new RuntimeStateEntry(pair.Key, pair.Value))
+ .OrderBy(pair => pair.Username, StringComparer.Ordinal)
+ .ToList());
+ }
+ }
+
+ private static Dictionary Load(string path)
+ {
+ if (!File.Exists(path))
+ {
+ return new Dictionary(StringComparer.Ordinal);
+ }
+
+ try
+ {
+ var options = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ ReadCommentHandling = JsonCommentHandling.Disallow,
+ };
+ using var stream = File.OpenRead(path);
+ var state = JsonSerializer.Deserialize(stream, options);
+ if (state is null)
+ {
+ throw new JsonException("State file is empty.");
+ }
+
+ var result = new Dictionary(StringComparer.Ordinal);
+ foreach (var entry in state.Entries)
+ {
+ if (string.IsNullOrWhiteSpace(entry.Username) || entry.Version == 0 || !result.TryAdd(entry.Username, entry.Version))
+ {
+ throw new InvalidOperationException("State file contains duplicate, empty, or zero versions.");
+ }
+ }
+
+ return result;
+ }
+ catch (Exception exception) when (exception is JsonException or InvalidOperationException)
+ {
+ throw new InvalidOperationException($"Invalid runtime state file '{path}': {exception.Message}", exception);
+ }
+ }
+
+ private static void WriteAtomic(string path, IReadOnlyList entries)
+ {
+ var options = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = true,
+ };
+ var json = JsonSerializer.Serialize(new RuntimeStateFile(entries), options);
+ var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None))
+ using (var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)))
+ {
+ writer.Write(json);
+ writer.Flush();
+ stream.Flush(flushToDisk: true);
+ }
+
+ if (File.Exists(path))
+ {
+ try
+ {
+ File.Replace(temporary, path, destinationBackupFileName: null, ignoreMetadataErrors: true);
+ }
+ catch (PlatformNotSupportedException)
+ {
+ File.Move(temporary, path, overwrite: true);
+ }
+ }
+ else
+ {
+ File.Move(temporary, path);
+ }
+ }
+ finally
+ {
+ if (File.Exists(temporary))
+ {
+ File.Delete(temporary);
+ }
+ }
+ }
+}
diff --git a/TextCascade.Server/ServerHost.cs b/TextCascade.Server/ServerHost.cs
index 4b9db50..de1e5c5 100644
--- a/TextCascade.Server/ServerHost.cs
+++ b/TextCascade.Server/ServerHost.cs
@@ -27,6 +27,8 @@ public static int RunServer(string[] args)
}
RuntimeConfig config;
+ UsersFile users;
+ RuntimeStateStore stateStore;
try
{
var configPath = args.Length > 0 && args[0] == "--config" ? args[1] : "textcascade.toml";
@@ -34,10 +36,10 @@ public static int RunServer(string[] args)
config = Config.LoadTomlConfig(configPath, config);
config = Config.ApplyEnvironmentOverrides(config);
Config.ValidateConfig(config);
- var users = UsersFile.LoadUsers(config.Files.UsersFile);
- SyncServer.Instance.Initialize(config, users);
+ users = UsersFile.LoadUsers(config.Files.UsersFile);
+ stateStore = new RuntimeStateStore(config.Files.StateFile);
}
- catch (Exception exception) when (exception is InvalidOperationException or JsonException or DecoderFallbackException)
+ catch (Exception exception) when (exception is InvalidOperationException or JsonException or DecoderFallbackException or IOException)
{
Console.Error.WriteLine($"Configuration error: {exception.Message}");
return Error;
@@ -56,7 +58,6 @@ public static int RunServer(string[] args)
using (certificate)
{
- RuntimeConfigAccessor.Current = config;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseKestrel(ConfigureKestrel(config, certificate));
builder.Logging.ClearProviders();
@@ -65,14 +66,27 @@ public static int RunServer(string[] args)
options.SingleLine = true;
options.TimestampFormat = "yyyy-MM-ddTHH:mm:ssZ ";
});
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton(stateStore);
+ builder.Services.AddSingleton(serviceProvider => new SyncServer(
+ config,
+ users,
+ serviceProvider.GetRequiredService(),
+ serviceProvider.GetRequiredService(),
+ serviceProvider.GetRequiredService(),
+ serviceProvider.GetRequiredService>()));
builder.Services.AddHostedService();
var app = builder.Build();
- SyncServer.Instance.Logger = app.Logger;
app.UseWebSockets();
app.MapGet("/health", () => Results.Json(new { status = "ok" }));
- app.MapPost("/api/v1/login", async context => await AuthService.HandleLoginAsync(context, config, app.Logger));
- app.MapGet("/api/v1/sync", async context => await SyncEndpoint.HandleAsync(context, config));
+ app.MapPost("/api/v1/login", async context => await AuthService.HandleLoginAsync(
+ context,
+ config,
+ context.RequestServices.GetRequiredService(),
+ app.Logger));
+ app.MapGet("/api/v1/sync", async context => await SyncEndpoint.HandleAsync(context, config, context.RequestServices.GetRequiredService()));
app.MapMethods("/health", new[] { "HEAD" }, () => Results.Json(new { status = "ok" }));
app.Run();
@@ -236,25 +250,32 @@ public sealed class HeartbeatScannerService : IHostedService, IDisposable
{
private Timer? timer;
+ private readonly SyncServer syncServer;
+
+ public HeartbeatScannerService(SyncServer syncServer)
+ {
+ this.syncServer = syncServer;
+ }
+
public Task StartAsync(CancellationToken cancellationToken)
{
timer = new Timer(Scan, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
return Task.CompletedTask;
}
- private static void Scan(object? state)
+ private void Scan(object? state)
{
var now = DateTimeOffset.UtcNow;
- SyncServer.Instance.ScanHeartbeats(now);
+ syncServer.ScanHeartbeats(now);
- var recoveryEnd = SyncServer.Instance.ProcessStartTime.AddSeconds(
- SyncServer.Instance.Config.Limits.SnapshotWindowSeconds);
+ var recoveryEnd = syncServer.ProcessStartTime.AddSeconds(
+ syncServer.Config.Limits.SnapshotWindowSeconds);
if (now < recoveryEnd)
{
return;
}
- foreach (var pair in SyncServer.Instance.Registry.All)
+ foreach (var pair in syncServer.Registry.All)
{
pair.Value.CloseRecoveryWindow(now);
}
@@ -263,7 +284,7 @@ private static void Scan(object? state)
public async Task StopAsync(CancellationToken cancellationToken)
{
timer?.Change(Timeout.Infinite, 0);
- await SyncServer.Instance.ShutdownAsync(TimeSpan.FromSeconds(2), DateTimeOffset.UtcNow);
+ await syncServer.ShutdownAsync(TimeSpan.FromSeconds(2), DateTimeOffset.UtcNow);
}
public void Dispose()
diff --git a/TextCascade.Server/SyncServer.cs b/TextCascade.Server/SyncServer.cs
index 2630dd1..1cb006c 100644
--- a/TextCascade.Server/SyncServer.cs
+++ b/TextCascade.Server/SyncServer.cs
@@ -123,6 +123,7 @@ public sealed class UserHub
public TokenBucket ClipBucket { get; }
public SeenIdRing SeenIds { get; }
public DateTimeOffset ProcessStartTime { get; }
+ public DateTimeOffset LastActivityAt => new(new DateTime(Interlocked.Read(ref lastActivityTicks), DateTimeKind.Utc));
private readonly object connectionsGate = new();
private readonly List connections = new();
@@ -135,15 +136,22 @@ public sealed class UserHub
private readonly List recoveryQueue = new();
private bool recoveryWindowClosed;
- public UserHub(string username, RuntimeConfig config, DateTimeOffset processStart)
+ private readonly SyncServer server;
+ private readonly RuntimeStateStore runtimeStateStore;
+ private long lastActivityTicks;
+
+ public UserHub(string username, RuntimeConfig config, DateTimeOffset processStart, SyncServer server, ulong initialVersion)
{
Username = username;
this.config = config;
+ this.server = server;
+ this.runtimeStateStore = server.RuntimeStateStore;
ProcessStartTime = processStart;
UserChannel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
ClipBucket = new TokenBucket(config.RateLimit.ClipBurst, config.RateLimit.ClipTokensPerSecond, processStart);
SeenIds = new SeenIdRing(config.Limits.SeenIdCapacity);
- Version = 0;
+ Version = initialVersion;
+ lastActivityTicks = processStart.UtcTicks;
}
public IReadOnlyList Connections
@@ -156,13 +164,18 @@ public bool IsEmpty
get { lock (connectionsGate) { return connections.Count == 0; } }
}
+ internal object ScanGate => connectionsGate;
+ internal List ConnectionList => connections;
+ internal RuntimeConfig Config => config;
+
public void AddConnection(ConnectionContext connection)
{
lock (connectionsGate) { connections.Add(connection); }
+ MarkActivity(DateTimeOffset.UtcNow);
var nowUtc = DateTimeOffset.UtcNow;
if (recoveryWindowClosed)
{
- BroadcastToConnection(connection, Protocol.SerializeWelcome(Latest));
+ BroadcastToConnection(connection, Protocol.SerializeWelcome(Latest, config.Limits));
return;
}
@@ -175,18 +188,32 @@ public void AddConnection(ConnectionContext connection)
public bool RemoveConnection(ConnectionContext connection)
{
- lock (connectionsGate) { return connections.Remove(connection); }
+ bool removed;
+ lock (connectionsGate) { removed = connections.Remove(connection); }
+ if (removed)
+ {
+ MarkActivity(DateTimeOffset.UtcNow);
+ }
+
+ return removed;
}
- public void StartIfIdle(Func processor)
+ public void StartIfIdle()
{
if (userLoop is null || userLoop.IsCompleted)
{
userLoop = Task.Run(async () =>
{
- try { await processor(this); }
+ try { await RunUserLoopAsync(); }
catch (OperationCanceledException) { }
- catch (Exception) { }
+ catch (Exception exception)
+ {
+ server.Logger.LogError(
+ exception,
+ "User loop failed; rebuilding hub. username={Username}",
+ Username);
+ server.RebuildHub(this);
+ }
});
}
}
@@ -223,7 +250,7 @@ private void ProcessJob(UserJob job, DateTimeOffset nowUtc)
}
break;
case DisconnectJob disconnectJob:
- SyncServer.Instance.CancelConnection(disconnectJob.Connection, disconnectJob.Reason);
+ server.CancelConnection(disconnectJob.Connection, disconnectJob.Reason);
break;
}
}
@@ -271,8 +298,21 @@ public void CloseRecoveryWindow(DateTimeOffset nowUtc)
winner = CoreLogic.SelectSnapshotWinner(snapshotCandidates);
if (winner is not null)
{
- Version = winner.Version;
- Latest = new LatestText(winner.Snapshot.Payload, winner.Version, winner.Snapshot.Hash, winner.Snapshot.Encrypted, winner.ClientId, winner.ClientName, winner.Snapshot.LocalModifiedAtUtc);
+ var canRestoreLatest = winner.Version > Version
+ || (winner.Version == Version && Latest is null);
+ if (!canRestoreLatest)
+ {
+ winner = null;
+ }
+ else
+ {
+ if (winner.Version > Version)
+ {
+ runtimeStateStore.SaveVersion(Username, winner.Version);
+ }
+ Version = winner.Version;
+ Latest = new LatestText(winner.Snapshot.Payload, winner.Version, winner.Snapshot.Hash, winner.Snapshot.Encrypted, winner.ClientId, winner.ClientName, winner.Snapshot.LocalModifiedAtUtc);
+ }
}
clips = recoveryQueue.ToList();
recoveryQueue.Clear();
@@ -287,12 +327,14 @@ public void CloseRecoveryWindow(DateTimeOffset nowUtc)
BroadcastWelcome(nowUtc);
// Spec §6.2: empty hubs that survived until the recovery window closes are now removed.
- SyncServer.Instance.Registry.RemoveIfEmpty(this, allowDuringRecovery: true);
+ server.Registry.RemoveIfEmpty(this, allowDuringRecovery: true);
+
+ MarkActivity(nowUtc);
}
private void BroadcastWelcome(DateTimeOffset nowUtc)
{
- var bytes = Protocol.SerializeWelcome(Latest);
+ var bytes = Protocol.SerializeWelcome(Latest, config.Limits);
foreach (var connection in Connections)
{
if (!connection.State.TryEnqueueSend(bytes) && connection.State.MarkClosed())
@@ -315,9 +357,16 @@ public void EnsureRecoveryWindowClosed(DateTimeOffset nowUtc)
}
}
+ private void MarkActivity(DateTimeOffset nowUtc)
+ {
+ Interlocked.Exchange(ref lastActivityTicks, nowUtc.UtcTicks);
+ }
+
+ internal void MarkActivityForScan(DateTimeOffset nowUtc) => MarkActivity(nowUtc);
+
public void ApplyClip(ClientClip clip, ConnectionContext sender, DateTimeOffset nowUtc)
{
- if (SeenIds.TryGetResult(clip.Id, out var duplicateLatest))
+ if (SeenIds.IsUnchangedDuplicate(clip.Id, clip.Payload, clip.Hash, clip.Encrypted, out var duplicateLatest))
{
var ackBytes = Protocol.SerializeClipAck(clip.Id, duplicateLatest ?? Latest ?? new LatestText(string.Empty, Version, string.Empty, false, sender.ClientId, sender.ClientName, nowUtc));
if (!sender.State.TryEnqueueSend(ackBytes) && sender.State.MarkClosed())
@@ -327,9 +376,19 @@ public void ApplyClip(ClientClip clip, ConnectionContext sender, DateTimeOffset
return;
}
+ if (SeenIds.TryGetResult(clip.Id, out _))
+ {
+ server.Logger.LogWarning(
+ "Replacing reused clip id. username={Username} clipId={ClipId} clientId={ClientId} previousVersion={PreviousVersion}",
+ Username,
+ clip.Id,
+ sender.ClientId,
+ Version);
+ }
+
if (!ClipBucket.TryAcquire(nowUtc))
{
- SyncServer.Instance.Logger?.LogSecurityEvent("reject",
+ server.Logger.LogSecurityEvent("reject",
("username", Username),
("code", "rate_limited"),
("bytes", Encoding.UTF8.GetByteCount(clip.Payload)));
@@ -342,27 +401,39 @@ public void ApplyClip(ClientClip clip, ConnectionContext sender, DateTimeOffset
}
var next = CoreLogic.NextVersion(Version);
+ runtimeStateStore.SaveVersion(Username, next);
Version = next;
var latest = new LatestText(clip.Payload, next, clip.Hash, clip.Encrypted, sender.ClientId, sender.ClientName, nowUtc);
Latest = latest;
SeenIds.RememberId(clip.Id, latest);
- SyncServer.Instance.Logger?.LogSecurityEvent("clip",
+ server.Logger.LogSecurityEvent("clip",
("username", Username),
("version", latest.Version),
+ ("clipId", clip.Id),
("bytes", Encoding.UTF8.GetByteCount(clip.Payload)),
("fromClientId", sender.ClientId),
("encrypted", clip.Encrypted));
var broadcastBytes = Protocol.SerializeClip(clip.Id, latest);
+ var deliveries = new List();
foreach (var connection in Connections)
{
if (ReferenceEquals(connection, sender)) continue;
- if (!connection.State.TryEnqueueSend(broadcastBytes) && connection.State.MarkClosed())
+ var queued = connection.State.TryEnqueueSend(broadcastBytes);
+ deliveries.Add($"{connection.ClientId}:{(queued ? "queued" : "full")}");
+ if (!queued && connection.State.MarkClosed())
{
connection.State.Cts.Cancel();
}
}
+ server.Logger.LogInformation(
+ "Clip broadcast. username={Username} version={Version} clipId={ClipId} recipients=[{Recipients}]",
+ Username,
+ next,
+ clip.Id,
+ string.Join(",", deliveries));
+
var ackBytesFinal = Protocol.SerializeClipAck(clip.Id, latest);
if (!sender.State.TryEnqueueSend(ackBytesFinal) && sender.State.MarkClosed())
{
@@ -370,26 +441,6 @@ public void ApplyClip(ClientClip clip, ConnectionContext sender, DateTimeOffset
}
}
- public void EnqueuePing(DateTimeOffset nowUtc)
- {
- var bytes = Protocol.SerializePing(nowUtc);
- foreach (var connection in Connections)
- {
- if (!connection.State.HelloReceived
- || nowUtc - connection.State.LastPingAt < TimeSpan.FromSeconds(config.Limits.HeartbeatIntervalSeconds))
- {
- continue;
- }
-
- connection.State.LastPingAt = nowUtc;
- connection.State.MarkPingAwaitingPong();
- if (!connection.State.TryEnqueueSend(bytes) && connection.State.MarkClosed())
- {
- connection.State.Cts.Cancel();
- }
- }
- }
-
private static void BroadcastToConnection(ConnectionContext connection, byte[] payload)
{
if (!connection.State.TryEnqueueSend(payload) && connection.State.MarkClosed())
@@ -448,6 +499,11 @@ public void RemoveIfEmpty(UserHub hub, bool allowDuringRecovery)
if (!allowDuringRecovery && hub.IsRecoveryWindowOpen(DateTimeOffset.UtcNow)) return;
hubs.TryRemove(hub.Username, out _);
}
+
+ public bool Remove(UserHub hub)
+ {
+ return hubs.TryRemove(new KeyValuePair(hub.Username, hub));
+ }
}
public interface IClock
@@ -462,55 +518,109 @@ public sealed class SystemClock : IClock
public sealed class SyncServer
{
- public static SyncServer Instance { get; } = new();
-
- private UserRegistry registry = new();
+ private readonly UserRegistry registry = new();
private readonly List pendingHellos = new();
private readonly object pendingGate = new();
+ private readonly IPasswordHasher hasher;
+ private readonly IClock clock;
+ private readonly RuntimeStateStore runtimeStateStore;
+ private readonly IReadOnlyDictionary userLookup;
public UserRegistry Registry => registry;
- public IPasswordHasher Hasher { get; set; } = new Argon2PasswordHasher();
+ public IPasswordHasher Hasher => hasher;
public SlidingWindowLoginLimiter LoginLimiter { get; } = new();
- public IClock Clock { get; set; } = new SystemClock();
- public ILogger? Logger { get; set; }
- public IReadOnlyDictionary UserLookup { get; set; } = new Dictionary(StringComparer.Ordinal);
- public DateTimeOffset ProcessStartTime { get; set; } = DateTimeOffset.UtcNow;
- public RuntimeConfig Config { get; private set; } = TextCascade.Server.Config.CreateDefaultConfig();
-
- public void Initialize(RuntimeConfig config, UsersFile users)
+ public IClock Clock => clock;
+ public ILogger Logger { get; }
+ public IReadOnlyDictionary UserLookup => userLookup;
+ public DateTimeOffset ProcessStartTime { get; }
+ public RuntimeConfig Config { get; }
+ public RuntimeStateStore RuntimeStateStore => runtimeStateStore;
+
+ public SyncServer(
+ RuntimeConfig config,
+ UsersFile users,
+ RuntimeStateStore runtimeStateStore,
+ IPasswordHasher hasher,
+ IClock clock,
+ ILogger logger)
{
Config = config;
- registry = new UserRegistry();
- UserLookup = UsersFile.BuildUserLookup(users);
- ProcessStartTime = DateTimeOffset.UtcNow;
+ userLookup = UsersFile.BuildUserLookup(users);
+ this.runtimeStateStore = runtimeStateStore;
+ this.hasher = hasher;
+ this.clock = clock;
+ Logger = logger;
+ ProcessStartTime = clock.UtcNow;
}
public UserHub GetOrCreateHub(string username, RuntimeConfig runtimeConfig)
{
- var hub = registry.GetOrAdd(username, name => new UserHub(name, runtimeConfig, ProcessStartTime));
- hub.StartIfIdle(hub => hub.RunUserLoopAsync());
+ var initialVersion = runtimeStateStore.GetVersion(username);
+ var hub = registry.GetOrAdd(username, name => new UserHub(name, runtimeConfig, ProcessStartTime, this, initialVersion));
+ hub.StartIfIdle();
return hub;
}
public void ScanHeartbeats(DateTimeOffset nowUtc)
{
- var timeout = RuntimeConfigAccessor.Current?.Limits.HeartbeatTimeoutSeconds ?? 60;
+ var timeout = Config.Limits.HeartbeatTimeoutSeconds;
foreach (var pair in registry.All)
{
- pair.Value.EnqueuePing(nowUtc);
- foreach (var connection in pair.Value.Connections)
+ var hub = pair.Value;
+ List? timedOut = null;
+ lock (hub.ScanGate)
{
- if (!connection.State.HelloReceived && connection.State.HelloDeadline is { } deadline && nowUtc >= deadline)
+ var pingInterval = TimeSpan.FromSeconds(hub.Config.Limits.HeartbeatIntervalSeconds);
+ var pingBytes = Protocol.SerializePing(nowUtc);
+ for (var index = hub.ConnectionList.Count - 1; index >= 0; index--)
{
- EnqueueHelloTimeout(connection);
- continue;
+ var connection = hub.ConnectionList[index];
+ if (!connection.State.HelloReceived && connection.State.HelloDeadline is { } deadline && nowUtc >= deadline)
+ {
+ timedOut ??= new List();
+ timedOut.Add(connection);
+ continue;
+ }
+
+ if (connection.State.HelloReceived && nowUtc - connection.State.LastPingAt >= pingInterval)
+ {
+ connection.State.LastPingAt = nowUtc;
+ connection.State.MarkPingAwaitingPong();
+ if (!connection.State.TryEnqueueSend(pingBytes) && connection.State.MarkClosed())
+ {
+ connection.State.Cts.Cancel();
+ }
+ }
+
+ if (nowUtc - connection.State.LastSeen >= TimeSpan.FromSeconds(timeout))
+ {
+ timedOut ??= new List();
+ timedOut.Add(connection);
+ }
+
+ hub.MarkActivityForScan(nowUtc);
}
- var elapsed = nowUtc - connection.State.LastSeen;
- if (elapsed.TotalSeconds >= timeout)
+ }
+
+ if (timedOut is not null)
+ {
+ foreach (var connection in timedOut)
{
- CancelConnection(connection, "heartbeat_timeout");
+ if (!connection.State.HelloReceived)
+ {
+ EnqueueHelloTimeout(connection);
+ }
+ else
+ {
+ CancelConnection(connection, "heartbeat_timeout");
+ }
}
}
+
+ if (hub.IsEmpty && nowUtc - hub.LastActivityAt >= TimeSpan.FromMinutes(10))
+ {
+ registry.RemoveIfEmpty(hub, allowDuringRecovery: false);
+ }
}
List expired = new();
@@ -531,6 +641,21 @@ public void ScanHeartbeats(DateTimeOffset nowUtc)
}
}
+ public void RebuildHub(UserHub hub)
+ {
+ if (!registry.Remove(hub))
+ {
+ return;
+ }
+
+ foreach (var connection in hub.Connections)
+ {
+ CancelConnection(connection, "user_loop_failed");
+ }
+
+ Registry.RemoveIfEmpty(hub, allowDuringRecovery: true);
+ }
+
public void RegisterPendingHello(ConnectionContext connection)
{
lock (pendingGate) { pendingHellos.Add(connection); }
@@ -552,7 +677,7 @@ private void EnqueueHelloTimeout(ConnectionContext connection)
_ = CloseAfterHelloTimeoutAsync(connection);
}
- private static async Task CloseAfterHelloTimeoutAsync(ConnectionContext connection)
+ private async Task CloseAfterHelloTimeoutAsync(ConnectionContext connection)
{
try
{
@@ -566,11 +691,11 @@ private static async Task CloseAfterHelloTimeoutAsync(ConnectionContext connecti
}
catch (Exception)
{
- Instance.EnqueueImmediateClose(connection, "server_busy");
+ EnqueueImmediateClose(connection, "server_busy");
}
finally
{
- Instance.CancelConnection(connection, "hello_timeout");
+ CancelConnection(connection, "hello_timeout");
}
}
@@ -582,7 +707,7 @@ public void CancelConnection(ConnectionContext connection, string reason)
connection.Hub?.RemoveConnection(connection);
if (connection.Hub is not null)
{
- Logger?.LogSecurityEvent("disconnect",
+ Logger.LogSecurityEvent("disconnect",
("username", connection.Username),
("clientId", connection.ClientId),
("connectionId", connection.ConnectionId),
@@ -646,7 +771,7 @@ private static async Task CloseConnectionAsync(ConnectionContext connection, Web
public static class SyncEndpoint
{
- public static async Task HandleAsync(HttpContext context, RuntimeConfig config)
+ public static async Task HandleAsync(HttpContext context, RuntimeConfig config, SyncServer server)
{
var tokenHeader = context.Request.Headers.Authorization.ToString();
if (!tokenHeader.StartsWith("Bearer ", StringComparison.Ordinal))
@@ -658,7 +783,7 @@ public static async Task HandleAsync(HttpContext context, RuntimeConfig config)
var compactToken = tokenHeader["Bearer ".Length..];
var now = DateTimeOffset.UtcNow;
var tokenService = new TokenService(config.TokenSecret!);
- if (!tokenService.TryVerifyToken(compactToken, now, SyncServer.Instance.UserLookup, out var payload))
+ if (!tokenService.TryVerifyToken(compactToken, now, server.UserLookup, out var payload))
{
context.Response.StatusCode = 401;
return;
@@ -680,7 +805,7 @@ public static async Task HandleAsync(HttpContext context, RuntimeConfig config)
using var socket = await context.WebSockets.AcceptWebSocketAsync(subProtocol);
var connectionId = Guid.NewGuid().ToString("N");
var provisional = new ConnectionContext(connectionId, payload.Subject, "pending", "pending", socket, null!, config);
- await ConnectionHandler.RunAsync(provisional, payload, config);
+ await ConnectionHandler.RunAsync(provisional, payload, config, server);
}
internal static string? SelectSubProtocol(IList requested)
@@ -695,9 +820,9 @@ public static async Task HandleAsync(HttpContext context, RuntimeConfig config)
public static class ConnectionHandler
{
- public static async Task RunAsync(ConnectionContext provisional, TokenPayload payload, RuntimeConfig config)
+ public static async Task RunAsync(ConnectionContext provisional, TokenPayload payload, RuntimeConfig config, SyncServer server)
{
- SyncServer.Instance.RegisterPendingHello(provisional);
+ server.RegisterPendingHello(provisional);
ClientHello hello;
try
{
@@ -708,7 +833,7 @@ await provisional.Socket.CloseOutputAsync(
WebSocketCloseStatus.NormalClosure,
"client_closed",
CancellationToken.None);
- SyncServer.Instance.CancelConnection(provisional, "closed");
+ server.CancelConnection(provisional, "closed");
return;
}
@@ -723,7 +848,8 @@ await SendAndClosePreHelloAsync(
provisional,
error,
WebSocketCloseStatus.PolicyViolation,
- "invalid_hello");
+ "invalid_hello",
+ server);
return;
}
@@ -732,23 +858,23 @@ await SendAndClosePreHelloAsync(
catch (FrameTooLargeException)
{
var error = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.FrameTooLarge, "frame_too_large", null));
- await SendAndClosePreHelloAsync(provisional, error, WebSocketCloseStatus.MessageTooBig, "frame_too_large");
+ await SendAndClosePreHelloAsync(provisional, error, WebSocketCloseStatus.MessageTooBig, "frame_too_large", server);
return;
}
catch (OperationCanceledException)
{
// Hello timeout is owned by the unified heartbeat scanner; here the socket was
// cancelled for another reason (e.g. shutdown). Fall through to unified cleanup.
- SyncServer.Instance.CancelConnection(provisional, "cancelled");
+ server.CancelConnection(provisional, "cancelled");
return;
}
catch (WebSocketException)
{
- SyncServer.Instance.CancelConnection(provisional, "socket_error");
+ server.CancelConnection(provisional, "socket_error");
return;
}
- var hub = SyncServer.Instance.GetOrCreateHub(payload.Subject, config);
+ var hub = server.GetOrCreateHub(payload.Subject, config);
var connection = new ConnectionContext(
provisional.ConnectionId,
payload.Subject,
@@ -758,23 +884,23 @@ await SendAndClosePreHelloAsync(
hub,
config);
hub.AddConnection(connection);
- SyncServer.Instance.Logger?.LogSecurityEvent("connect",
+ server.Logger.LogSecurityEvent("connect",
("username", connection.Username),
("clientId", connection.ClientId),
("connectionId", connection.ConnectionId));
connection.State.HelloReceived = true;
connection.State.LastSeen = DateTimeOffset.UtcNow;
- SyncServer.Instance.UnregisterPendingHello(provisional);
+ server.UnregisterPendingHello(provisional);
if (!hub.TryWriteJob(new HelloJob(connection, hello)))
{
- SyncServer.Instance.CancelConnection(connection, "user_loop_unavailable");
+ server.CancelConnection(connection, "user_loop_unavailable");
return;
}
var sendTask = ConnectionSendLoopAsync(connection);
- var readTask = ReadLoopAsync(connection, config);
+ var readTask = ReadLoopAsync(connection, config, server);
await Task.WhenAll(sendTask, readTask);
- SyncServer.Instance.CancelConnection(connection, "disconnected");
+ server.CancelConnection(connection, "disconnected");
}
private static async Task ReceiveFrameAsync(
@@ -804,7 +930,8 @@ private static async Task SendAndClosePreHelloAsync(
ConnectionContext connection,
byte[] error,
WebSocketCloseStatus status,
- string reason)
+ string reason,
+ SyncServer server)
{
try
{
@@ -816,39 +943,15 @@ private static async Task SendAndClosePreHelloAsync(
}
catch (Exception)
{
- SyncServer.Instance.EnqueueImmediateClose(connection, "server_busy");
- }
- finally
- {
- SyncServer.Instance.CancelConnection(connection, reason);
- }
- }
-
- private static async Task CloseAfterProtocolErrorAsync(
- ConnectionContext connection,
- WebSocketCloseStatus status,
- string reason)
- {
- // Give the send loop a short opportunity to flush the queued error frame.
- await Task.Delay(100);
- try
- {
- if (connection.Socket.State == WebSocketState.Open)
- {
- await connection.Socket.CloseAsync(status, reason, CancellationToken.None);
- }
- }
- catch (Exception)
- {
- // Cancellation below is still the unified cleanup path.
+ server.EnqueueImmediateClose(connection, "server_busy");
}
finally
{
- SyncServer.Instance.CancelConnection(connection, reason);
+ server.CancelConnection(connection, reason);
}
}
- private static async Task ReadLoopAsync(ConnectionContext connection, RuntimeConfig config)
+ private static async Task ReadLoopAsync(ConnectionContext connection, RuntimeConfig config, SyncServer server)
{
try
{
@@ -862,13 +965,13 @@ private static async Task ReadLoopAsync(ConnectionContext connection, RuntimeCon
catch (FrameTooLargeException)
{
var oversized = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.FrameTooLarge, "frame_too_large", null));
- await SendSafeAsync(connection, oversized);
+ await SendSafeAsync(connection, oversized, server);
await Task.Delay(100, connection.State.Cts.Token);
if (connection.Socket.State == WebSocketState.Open)
{
await connection.Socket.CloseAsync(WebSocketCloseStatus.MessageTooBig, "frame_too_large", CancellationToken.None);
}
- SyncServer.Instance.CancelConnection(connection, "frame_too_large");
+ server.CancelConnection(connection, "frame_too_large");
break;
}
@@ -888,21 +991,21 @@ await connection.Socket.CloseOutputAsync(
if (!Protocol.CheckFrameSize(received.Payload.Length, config))
{
var error = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.FrameTooLarge, "frame_too_large", null));
- await SendSafeAsync(connection, error);
+ await SendSafeAsync(connection, error, server);
await connection.Socket.CloseAsync(WebSocketCloseStatus.MessageTooBig, "frame_too_large", CancellationToken.None);
- SyncServer.Instance.CancelConnection(connection, "frame_too_large");
+ server.CancelConnection(connection, "frame_too_large");
break;
}
var parse = Protocol.ParseClientMessage(received.Payload, config);
if (!parse.IsSuccess)
{
- SyncServer.Instance.Logger?.LogSecurityEvent("reject",
+ server.Logger.LogSecurityEvent("reject",
("username", connection.Username),
("code", parse.Error?.CodeName ?? "invalid_message"),
("bytes", received.Payload.Length));
var error = Protocol.SerializeProtocolError(parse.Error!);
- await SendSafeAsync(connection, error);
+ await SendSafeAsync(connection, error, server);
continue;
}
@@ -913,19 +1016,19 @@ await connection.Socket.CloseOutputAsync(
var hub = connection.Hub;
if (hub is null)
{
- SyncServer.Instance.CancelConnection(connection, "user_loop_unavailable");
+ server.CancelConnection(connection, "user_loop_unavailable");
break;
}
var decision = hub.ClassifyClip(clip, connection);
if (decision == RecoveryDecision.QueueFull)
{
- SyncServer.Instance.CancelConnection(connection, "recovery_queue_full");
+ server.CancelConnection(connection, "recovery_queue_full");
}
else if (decision == RecoveryDecision.ProcessNow
&& !hub.TryWriteJob(new ClipJob(connection, clip)))
{
- SyncServer.Instance.CancelConnection(connection, "user_loop_unavailable");
+ server.CancelConnection(connection, "user_loop_unavailable");
}
break;
case MessageKind.Pong:
@@ -935,13 +1038,13 @@ await connection.Socket.CloseOutputAsync(
ProtocolErrorCode.InvalidMessage,
"Pong received without an outstanding ping.",
null));
- await SendSafeAsync(connection, unsolicitedPong);
+ await SendSafeAsync(connection, unsolicitedPong, server);
continue;
}
if (connection.Hub is null || !connection.Hub.TryWriteJob(new PongJob(connection, (ClientPong)parse.Message!)))
{
- SyncServer.Instance.CancelConnection(connection, "user_loop_unavailable");
+ server.CancelConnection(connection, "user_loop_unavailable");
}
break;
}
@@ -964,11 +1067,11 @@ private static async Task ConnectionSendLoopAsync(ConnectionContext connection)
catch (WebSocketException) { }
}
- private static async Task SendSafeAsync(ConnectionContext connection, byte[] payload)
+ private static async Task SendSafeAsync(ConnectionContext connection, byte[] payload, SyncServer server)
{
if (!connection.State.TryEnqueueSend(payload))
{
- SyncServer.Instance.EnqueueImmediateClose(connection, "server_busy");
+ server.EnqueueImmediateClose(connection, "server_busy");
return;
}
}
diff --git a/TextCascade.Server/TextCascade.Server.csproj b/TextCascade.Server/TextCascade.Server.csproj
index db51706..61d9f16 100644
--- a/TextCascade.Server/TextCascade.Server.csproj
+++ b/TextCascade.Server/TextCascade.Server.csproj
@@ -4,7 +4,7 @@
net10.0
enable
enable
- 0.2.1
+ 0.2.5
TextCascade.Server
true
diff --git a/deploy/textcascade-server.service b/deploy/textcascade-server.service
index b479b03..fac8210 100644
--- a/deploy/textcascade-server.service
+++ b/deploy/textcascade-server.service
@@ -5,11 +5,13 @@ After=network-online.target
[Service]
Type=simple
-User=root
-Group=root
+User=textcascade
+Group=textcascade
WorkingDirectory=/opt/textcascade-server
EnvironmentFile=/etc/textcascade/textcascade.env
-ExecStart=/usr/bin/dotnet /opt/textcascade-server/TextCascade.Server.dll serve --config /etc/textcascade/textcascade.toml
+ExecStart=/opt/textcascade-server/TextCascade.Server serve --config /etc/textcascade/textcascade.toml
+StateDirectory=textcascade
+ConfigurationDirectory=textcascade
Restart=on-failure
RestartSec=5s
UMask=0077
diff --git a/deploy/textcascade.toml b/deploy/textcascade.toml
index 843b93c..f9e6fa4 100644
--- a/deploy/textcascade.toml
+++ b/deploy/textcascade.toml
@@ -5,3 +5,4 @@ certificate_path = "/etc/cert/test.pem"
[files]
users_file = "/etc/textcascade/users.json"
+state_file = "/var/lib/textcascade/textcascade.state.json"
From eeefa35a195d2e4c6b86e5c79abd94933ddaf690 Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Sat, 22 Aug 2026 14:32:26 +0800
Subject: [PATCH 06/32] add spec of server
---
docs/server-spec.md | 857 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 857 insertions(+)
create mode 100644 docs/server-spec.md
diff --git a/docs/server-spec.md b/docs/server-spec.md
new file mode 100644
index 0000000..76ed45e
--- /dev/null
+++ b/docs/server-spec.md
@@ -0,0 +1,857 @@
+# TextCascade 轻量文本同步服务端规格
+
+状态:函数级设计已定稿,已按审查决策台账修订
+日期:2026-08-18
+协议目标:不兼容原 ClipCascade,只做轻量、可靠、高性能的文本最新值同步
+
+## 1. 目标与非目标
+
+### 1.1 目标
+
+- 仅同步文本最新值:每用户只保存一份当前文本,不保存历史。
+- 无数据库:账号使用 `users.json`,文本与版本只在内存中。
+- 服务端重启可恢复:客户端用无状态 token 重连,并在恢复窗口内上报 snapshot。
+- 低空闲资源占用:无数据库轮询、无磁盘写入、无 WebUI、无 metrics endpoint。
+- 明确边界:协议错误显式返回,慢连接被隔离或断开,绝不拖垮整个服务。
+- 三端协议实现:服务端与桌面端使用 C#,Android 端使用 Kotlin;三端手写模型,由服务端契约测试约束。
+
+### 1.2 非目标
+
+- 不兼容原 ClipCascade 的 Spring、CSRF、JSESSIONID、STOMP 协议。
+- 不支持图片、文件、剪贴板历史、离线消息队列、逐设备 ACK 状态。
+- 不支持多实例分布式部署、数据库后端、管理后台或 WebUI。
+- 不提供 Prometheus metrics;内部只保留轻量计数器。
+- 不做 Socket.IO;使用原生 WebSocket。
+
+## 2. 已定架构
+
+### 2.1 运行时与进程
+
+- 技术栈:ASP.NET Core Minimal API + Kestrel 原生 WebSocket。
+- 目标框架:`net10.0`;产品版本采用 SemVer,从 `0.1.0` 开始。
+- 进程模型:单进程;生产环境由 systemd 或 Windows Service 托管并负责崩溃自动重启。
+- TLS:Kestrel 直接终止 TLS;不提供生产/开发模式开关,所有部署都禁止明文 HTTP 登录。
+- 部署产物:框架依赖单文件;目标机必须预装对应 .NET Runtime。
+
+### 2.2 核心对象
+
+- `ConnectionContext`:不可变稳定属性,包括连接 ID、用户名、clientId、socket、认证信息。
+- `ConnectionStateBag`:可变运行时状态,包括 lastSeen、关闭标记、发送 Channel;修改必须收敛到少数明确函数。
+- `UserHub`:每个在线用户一个 hub,持有最新值、版本号、幂等队列、令牌桶与用户 Channel。
+- `UserRegistry`:`ConcurrentDictionary`,不同用户天然并发。
+- `LatestText`:不可变 record,包含 payload、version、来源、更新时间;更新即替换引用。
+
+### 2.3 并发模型
+
+1. 每个连接一个独立 `ReadLoopAsync`。
+2. 读循环只负责收帧、解析、验证,然后把用户级 job 投递到 UserHub Channel。
+3. 每个 UserHub 一个 `UserLoopAsync` 单消费者,串行处理该用户的 clip、连接、断开与恢复 job。
+4. 广播时只序列化一次 UTF-8 字节,并把同一份字节投递到每个连接的有界发送 Channel。
+5. 每个连接一个 `ConnectionSendLoopAsync`,慢连接只积压自己的队列。
+6. 发送队列满立即取消该连接,不等待 drain,不补发应用层 error 或 WebSocket close frame。
+
+## 3. 配置与用户
+
+### 3.1 配置函数
+
+- `CreateDefaultConfig()`:内置安全默认值。
+- `LoadTomlConfig(path)`:读取可选 TOML 配置并覆盖默认值。
+- `ApplyEnvironmentOverrides(config)`:环境变量覆盖敏感项与非默认部署值。
+- `ValidateConfig(config)`:启动时强校验;非法值 fail-fast。
+- `BuildWebHost(config)`:创建 Minimal API 应用并绑定 Kestrel。
+
+默认配置文件示例:
+
+```toml
+[server]
+bind = "0.0.0.0"
+port = 8443
+certificate_path = "certs/server.pfx"
+
+[auth]
+token_ttl_days = 30
+token_secret_env = "TEXTCASCADE_TOKEN_SECRET"
+argon2_memory_kib = 19456
+argon2_iterations = 2
+argon2_parallelism = 1
+
+[limits]
+max_text_bytes = 524288
+max_frame_bytes = 589824
+send_queue_capacity = 16
+seen_id_capacity = 64
+hello_timeout_seconds = 5
+heartbeat_interval_seconds = 30
+heartbeat_timeout_seconds = 60
+snapshot_window_seconds = 3
+snapshot_total_bytes = 4194304
+recovery_clip_queue_capacity = 16
+
+[rate_limit]
+login_ip_per_minute = 10
+login_user_per_minute = 5
+max_keys = 10000
+clip_burst = 10
+clip_tokens_per_second = 2
+
+[files]
+users_file = "users.json"
+```
+
+规则:
+
+- 服务端不提供 production/environment 模式开关,安全校验不因部署环境降低。
+- `token_secret_env` 指向环境变量名;token secret 不写入 TOML。
+- token secret 必须由环境变量提供,长度至少 32 字节;缺失或过短时启动失败。
+- TLS 始终启用;`certificate_path` 必须指向服务端可用证书。
+- 证书仅支持无密码格式:`.pem` / `.crt` 必须是包含叶证书与未加密私钥的 PEM bundle,`.pfx` 必须可无密码加载;带密码证书不支持,遇到需要密码的 PFX 时启动失败。
+- TOML 使用宽松解析:必须以 UTF-8 读取;未知键忽略并输出 warning;重复键采用后值并输出 warning;结构或类型非法仍 fail-fast。
+- `max_frame_bytes` 必须大于 `max_text_bytes`,差额留给 JSON 协议头。
+- 所有容量与时间配置必须大于 0,心跳超时必须大于心跳间隔。
+
+### 3.2 用户存储
+
+文件:`users.json`
+
+```json
+{
+ "nextTokenVersion": 3,
+ "users": [
+ {
+ "username": "alice",
+ "passwordHash": "$argon2id$...",
+ "tokenVersion": 1,
+ "disabled": false
+ }
+ ]
+}
+```
+
+函数:
+
+- `LoadUsers(path)`:启动时全量读取。
+- `ValidateUsers(users)`:校验 `nextTokenVersion` 必填且大于所有用户 `tokenVersion`;校验用户名唯一、哈希格式、正数 `long` tokenVersion 与 disabled 字段。
+- `BuildUserLookup(users)`:构造只读用户查找表。
+
+说明:
+
+- 不热加载用户文件,避免在线连接认证状态与文件状态竞态。
+- `tokenVersion` 不是软件版本,而是账号 token 作废计数器。
+- `tokenVersion` 与 `nextTokenVersion` 使用有符号 64 位整数(`long`),只允许正数,创建与递增时溢出即 fail-fast。
+- `nextTokenVersion` 是全局水位;新增用户取当前水位作为 `tokenVersion`,随后水位加一。
+- `revoke-tokens` 将目标用户 `tokenVersion` 更新为当前水位,随后水位加一,保证未来新建任何账号都不会复用已撤销版本。
+- CLI 写入用户文件前必须先通过 `ValidateUsers(users)`;水位递增溢出或校验失败时放弃替换并保留原文件。
+- CLI 使用 PID 锁文件实现单实例:同一时刻只允许一个 TextCascade CLI 进程运行;检测到仍存活的其他 CLI 进程时,新实例直接失败退出。
+- PID 锁文件覆盖 CLI 生命周期;实现必须识别并回收陈旧 PID、进程已退出但锁文件残留的情况,并在 Windows 与 Linux 上行为一致。
+- 支持直接删除用户条目,不保留墓碑;之后重建同名用户时从全局水位取新 `tokenVersion`,因此不会落入旧 token 的版本空档。
+- 删除或修改用户文件后需重启服务生效;重启后已删除用户不存在,其 token 因用户查找失败而失效。
+
+### 3.3 用户 CLI
+
+入口在同一服务端可执行文件中,不提供 WebUI。
+
+```bash
+TextCascade.Server user add --username alice
+TextCascade.Server user passwd --username alice
+TextCascade.Server user disable --username alice
+TextCascade.Server user enable --username alice
+TextCascade.Server user delete --username alice
+TextCascade.Server user revoke-tokens --username alice
+TextCascade.Server user list
+TextCascade.Server user hash
+```
+
+函数:
+
+- `RunCli(args)`:识别 `user` 子命令。
+- `CommandAddUser()`:生成 Argon2id 哈希,从全局水位分配 `tokenVersion`,并原子重写用户文件。
+- `CommandDeleteUser()`:直接删除用户条目并原子重写文件,不写入墓碑。
+- `CommandHashPassword()`:只输出密码哈希。
+- `CommandListUsers()`:只输出用户名、禁用状态与 tokenVersion,不输出哈希。
+
+CLI 写入 `users.json` 时先持有 PID 单实例锁,再使用临时文件加原子替换;服务端运行中修改文件不会热生效,需重启。服务端不写入用户文件。
+
+## 4. HTTP API
+
+### 4.1 登录
+
+```http
+POST /api/v1/login
+Content-Type: application/json
+```
+
+请求:
+
+```json
+{
+ "username": "alice",
+ "password": "raw-password"
+}
+```
+
+函数:
+
+- `MapLoginEndpoint()`:薄 Endpoint,只处理 HTTP 请求与响应。
+- `AuthService.LoginAsync()`:执行认证、tokenVersion 校验、token 签发。
+- `ParseLoginRequest()`:限制请求体 16KB、JSON 深度 3。
+- `AuthenticateUser()`:Argon2id 常数时间校验。
+- `CreateLoginFailure()`:统一返回 `invalid_credentials`。
+- `CreateRateLimitResult()`:统一返回 `429`。
+
+成功:
+
+```json
+{
+ "token": "",
+ "expiresAtUtc": "2026-09-17T00:00:00Z",
+ "protocolVersion": 1,
+ "maxTextBytes": 524288,
+ "helloTimeoutSeconds": 5,
+ "heartbeatIntervalSeconds": 30,
+ "heartbeatTimeoutSeconds": 60
+}
+```
+
+失败:
+
+```http
+401 Unauthorized
+```
+
+```json
+{
+ "error": "invalid_credentials",
+ "message": "Invalid username or password."
+}
+```
+
+规则:
+
+- 客户端通过 TLS 发送原始密码;客户端不做 Argon2id。
+- 用户不存在与密码错误返回相同错误,避免枚举用户。
+- Argon2id 参数变化时,登录路径只调用 `NeedsRehash()` 输出结构化 warning,不重写 `users.json`;用户通过 CLI `passwd` 设置新密码时才生成当前参数的哈希。
+- 登录限流命中返回 `429 Too Many Requests`,错误码 `rate_limited`。
+
+### 4.2 Token
+
+格式:
+
+```text
+base64url(payload).base64url(hmac-sha256(payload, secret))
+```
+
+payload:
+
+```json
+{
+ "sub": "alice",
+ "ver": 1,
+ "iat": 1760000000,
+ "exp": 1762592000
+}
+```
+
+Token JSON 规则:
+
+- 服务端签发时按 `sub`、`ver`、`iat`、`exp` 固定字段序输出最小化 UTF-8 JSON。
+- 验证时字段顺序无关,但拒绝重复字段与未知字段。
+- `sub` 是非空用户名;`ver`、`iat`、`exp` 均为有符号 64 位整数范围内的正整数;`exp` 必须大于 `iat`。
+- 数字不得以小数、指数或字符串形式表示。
+
+函数:
+
+- `CreateTokenPayload(user, now, ttl)`:生成 `sub`、`ver`、`iat`、`exp`。
+- `SignToken(payload, secret)`:HMAC-SHA256。
+- `VerifyToken(compact, secret, now, userLookup)`:验签、验过期、验用户存在、验 tokenVersion。
+
+规则:
+
+- HMAC 比较必须常数时间。
+- token 默认 30 天,可由配置调整。
+- token 无服务端状态,服务端重启后仍可验证。
+- 用户被禁用、删除或 tokenVersion 变化后,重启服务即可拒绝旧 token;删除后重建同名用户会从全局水位分配更高 tokenVersion。
+
+### 4.3 登录限流
+
+函数:
+
+- `TryConsumeLoginLimit(ip, username, now)`:进程内滑动窗口。
+- `ResetUserLoginLimit(username)`:仅在认证成功后清空该用户名窗口。
+
+策略:
+
+- IP 与用户名双维度限流,任一超限即拒绝。
+- 默认每 IP 每分钟 10 次,每用户名每分钟 5 次。
+- 用户名维度统计所有登录请求,无论认证成功或失败;认证成功后清空该用户名窗口。
+- IP 维度统计所有登录请求;认证成功不清空 IP 窗口。
+- 限流器设置最大 key 数,提供确定内存上限;达到上限时先清理全部过期项,仍满则拒绝新 key 的登录请求并返回 `429 rate_limited`。
+- 未达到上限时只保存窗口内时间戳,过期项在该 key 被访问时惰性清理;已有 key 的请求不创建新条目。
+- 单实例部署下不做分布式限流。
+- 已知取舍:持有正确密码的攻击者可通过高频成功登录占满目标用户名窗口;v1 接受该风险,以换取更简单的计数与重置规则。
+
+### 4.4 健康检查
+
+```http
+GET /health
+```
+
+函数:
+
+- `MapHealth()`:进程能响应即返回 `200 OK`。
+
+返回:
+
+```json
+{
+ "status": "ok"
+}
+```
+
+不暴露连接数、内存、用户数等内部统计。
+
+## 5. WebSocket 协议
+
+### 5.1 连接建立
+
+```http
+GET /api/v1/sync
+Authorization: Bearer
+Sec-WebSocket-Protocol: textcascade.v1
+Upgrade: websocket
+```
+
+函数:
+
+- `AuthenticateUpgradeRequest(httpContext)`:升级前验 token。
+- `SelectSubProtocol(requestProtocols)`:只接受 `textcascade.v1`。
+- `AcceptAuthenticatedSocket(httpContext)`:认证与版本都合法才升级。
+
+规则:
+
+- token 放 Authorization header,不进 URL。
+- token 无效、过期、用户禁用或 tokenVersion 不匹配时,不升级 WebSocket,直接返回 `401`。
+- 子协议不匹配返回 `400`。
+- 认证成功后,客户端必须在 `hello_timeout_seconds` 内发送 hello,用于注册设备与上报 snapshot;默认 5 秒。
+- hello 通过验证前,连接不进入广播列表;该超时由服务端统一计时。
+
+### 5.2 Client Hello
+
+```json
+{
+ "type": "hello",
+ "clientId": "stable-device-id",
+ "clientName": "Windows-Desktop",
+ "lastServerVersion": 128,
+ "snapshot": {
+ "payload": "...",
+ "encrypted": true,
+ "hash": "client-local-hash",
+ "localModifiedAtUtc": "2026-08-18T08:00:00Z"
+ }
+}
+```
+
+函数:
+
+- `ParseHello(frame)`:解析 hello。
+- `ValidateHello(hello)`:校验 clientId、clientName、snapshot 与版本字段。
+- `CreateConnectionContext(socket, user, hello)`:创建不可变连接上下文。
+
+字段:
+
+- `clientId`:稳定设备 ID,长度 1-128。
+- `clientName`:可选,长度 0-128。
+- `lastServerVersion`:客户端见过的最后服务端版本;未知为 0。
+- `snapshot`:可选。仅在进程启动后的全局恢复窗口内用于选举最新值;恢复窗口结束后只执行完整协议校验,校验通过即丢弃,不写入最新值。clip 是唯一文本写入路径。
+
+### 5.3 Server Welcome
+
+```json
+{
+ "type": "welcome",
+ "protocolVersion": 1,
+ "latest": {
+ "version": 128,
+ "payload": "...",
+ "encrypted": true,
+ "hash": "...",
+ "fromClientId": "android-a",
+ "updatedAtUtc": "2026-08-18T07:59:58Z"
+ }
+}
+```
+
+函数:
+
+- `CreateWelcome(latest)`:构造欢迎消息。
+- `SerializeMessage(message)`:System.Text.Json 序列化为 UTF-8。
+
+规则:
+
+- 服务端内存无最新值时 `latest` 为 `null`。
+- 恢复窗口内可先等待 snapshot 选举,再发送 welcome。
+- 客户端收到相同 hash 或相同版本时可本地去重,不写剪贴板;hash 只用于本地剪贴板去重,服务端新旧值以版本为准。
+
+### 5.4 发布文本
+
+客户端:
+
+```json
+{
+ "type": "clip",
+ "id": "client-generated-unique-id",
+ "payload": "...",
+ "encrypted": true,
+ "hash": "..."
+}
+```
+
+服务端广播给同用户除发送方连接外的其他在线连接:
+
+```json
+{
+ "type": "clip",
+ "version": 129,
+ "id": "client-generated-unique-id",
+ "payload": "...",
+ "encrypted": true,
+ "hash": "...",
+ "fromClientId": "windows-a",
+ "fromClientName": "Windows-Desktop",
+ "updatedAtUtc": "2026-08-18T08:01:00Z"
+}
+```
+
+发送方收到 ACK:
+
+```json
+{
+ "type": "clip_ack",
+ "id": "client-generated-unique-id",
+ "version": 129,
+ "updatedAtUtc": "2026-08-18T08:01:00Z"
+}
+```
+
+函数:
+
+- `ValidateClipMessage(message)`:单函数完整验证,内部按结构、语义、资源顺序早拒绝。
+- `CheckFrameSize(frameLength, config)`:WebSocket 完整帧字节数硬限制。
+- `CheckPayloadSize(payloadUtf8Length, config)`:文本字段独立限额。
+- `UserHub.TryDuplicate(id)`:用户级环形队列去重。
+- `RememberId(id)`:记录最近消息 ID。
+- `TryAcquireClipToken(now)`:用户级令牌桶。
+- `NextVersion(current)`:服务端权威 `ulong` 自增;溢出抛 fatal。
+- `WithVersion(latest, next)`:构造新的不可变 LatestText。
+- `BroadcastAsync(userHub, latest)`:一次序列化、多连接投递。
+
+规则:
+
+- 客户端不携带版本号;版本由服务端按用户处理顺序生成。
+- `id` 重复时不生成新版本,先返回原 ACK,且不消耗用户级令牌桶;重复 ACK 仍必须进入发送方的有界发送队列,队列满时按慢连接取消。
+- 空文本、非法 UTF-8、结构缺字段、超帧、超文本、限流超限均拒绝。
+- `payload` 对服务端 opaque;`encrypted=true` 时服务端不解析内容。
+- 发送队列容量按消息条数计算,默认 16;队列满立即取消连接,不补发 error 或 close frame。
+- 慢设备延迟到达的旧 clip 仍会获得新版本并覆盖最新值;这是最新值语义的预期行为,客户端需自行处理可能的回滚。
+
+### 5.5 心跳
+
+服务端定时发送应用层 JSON ping:
+
+```json
+{
+ "type": "ping",
+ "serverTimeUtc": "2026-08-18T08:02:00Z"
+}
+```
+
+客户端必须返回:
+
+```json
+{
+ "type": "pong",
+ "clientTimeUtc": "2026-08-18T08:02:00Z"
+}
+```
+
+函数:
+
+- `StartHeartbeatTimer()`:统一扫描所有连接。
+- `SendPing(connection)`:发送 ping。
+- `MarkPongReceived(connection, now)`:更新 lastSeen。
+- `CloseExpiredConnections()`:超时未收到 pong 则取消连接。
+
+说明:
+
+- 心跳使用应用层 JSON 消息,便于服务端记录 pong 时间并在三端保持一致行为。
+- 默认 30 秒发送一次,60 秒未收到 pong 判定死亡。
+- 统一扫描器代替每连接独立 timer,降低空闲调度开销。
+- 统一扫描器固定每 1 秒扫描一次;hello 与心跳超时允许 0-1 秒的额外检测延迟,不提供独立配置项。
+
+### 5.6 错误
+
+```json
+{
+ "type": "error",
+ "code": "text_too_large",
+ "message": "Text exceeds maxTextBytes.",
+ "referenceId": "client-generated-unique-id"
+}
+```
+
+函数:
+
+- `ParseResult`:成功或错误显式返回。
+- `CreateProtocolError(code, message, referenceId)`:构造错误。
+- `SendProtocolErrorAsync(connection, error)`:发送可继续错误。
+- `EnqueueImmediateClose(connection, reason)`:跳过 error 与 close frame,直接进入统一取消路径;仅用于发送队列满等无法安全写入的场景。
+
+错误码:
+
+| code | 含义 | 连接处理 |
+|---|---|---|
+| `invalid_message` | JSON 结构或字段非法 | 可继续 |
+| `text_too_large` | 文本字段超限 | 可继续 |
+| `frame_too_large` | 完整帧超限 | 关闭 1009 |
+| `empty_text` | 空文本 | 可继续 |
+| `rate_limited` | 用户级发送限流 | 可继续 |
+| `hello_timeout` | 未按时发送 hello | 先发 error,关闭 1008 |
+| `server_busy` | 发送队列拥塞 | 立即取消;该错误不保证发送 |
+
+错误处理顺序:
+
+- 需要关闭的错误必须先发送对应应用层 error 帧,再执行 WebSocket close;同一连接同类错误只触发一次关闭流程。
+- 慢连接发送队列满时不补发应用层 error,也不写 close frame,直接进入取消路径;`server_busy` 语义对客户端不可靠,客户端应靠重连兜底。
+
+可预期协议错误走 Result;不可预期异常仍由顶层兜底并进入统一清理。
+
+### 5.7 关闭与清理
+
+函数:
+
+- `CancelConnection(connection, reason)`:唯一取消入口,触发 CancellationTokenSource。
+- `FinallyCloseConnection(connection)`:统一关闭 socket、停止任务、从 UserHub 摘除。
+- `RemoveEmptyHub(userRegistry, userHub)`:最后一个连接断开后清理空 hub;全局恢复窗口内不执行空 hub 清理,窗口收尾时仍无连接的 hub 才移除。
+
+| close code | 含义 |
+|---:|---|
+| `1000` | 正常关闭 |
+| `1001` | 服务端重启或维护 |
+| `1008` | 策略关闭,例如 hello 超时 |
+| `1009` | 帧过大 |
+
+hello 超时先发送 `hello_timeout` error,再以 `1008` close;发送队列满则不补发 error,直接取消。`1013` 与 `4408` 不是本协议 close code,客户端不得依赖。
+
+心跳超时、慢连接、客户端断开、协议异常都必须汇入同一 CTS 取消路径,避免重复清理和资源泄漏。
+
+## 6. 最新值与恢复
+
+### 6.1 正常运行
+
+每个用户只保存一个 `LatestText`:
+
+- `payload`
+- `version`
+- `hash`
+- `fromClientId`
+- `fromClientName`
+- `updatedAtUtc`
+
+处理顺序:
+
+1. 读循环收帧并检查帧大小。
+2. JSON 解析与 `ValidateClipMessage`。
+3. 投递到用户 Channel。
+4. 用户单消费者执行幂等检查与令牌桶。
+5. `NextVersion` 生成新版本。
+6. 不可变替换最新值。
+7. 广播给除发送者外的连接,并向发送者返回 ACK。
+
+这是最新值语义,不是可靠队列语义。离线设备不补历史,重连后只拿当前最新值。
+
+### 6.2 服务端重启恢复
+
+恢复窗口从服务端进程启动时间起算,结束时间为 `processStartTime + snapshot_window_seconds`。该窗口对全部用户统一生效,不按 UserHub 创建时间或首个 hello 到达时间重新计算。
+
+函数:
+
+- `CollectSnapshotsAsync(userHub, window)`:收集 3 秒恢复窗口内的 snapshot。
+- `SelectSnapshotWinner(candidates)`:按确定性规则选举。
+- `RestoreLatestText(userHub, winner)`:恢复最新值与版本基准。
+
+选举规则:
+
+1. `lastServerVersion=0` 的 snapshot 不参与选举;只过滤出正版本候选。
+2. 若没有正版本候选,恢复结果为空,不下发最新值。
+3. 在正版本候选中优先选择 `lastServerVersion` 最大者。
+4. 若版本相同,选择 `localModifiedAtUtc` 最新者。
+5. 若仍相同,选择 `clientId` 字典序更大者,保证结果确定。
+
+恢复规则:
+
+- winner 的 `LatestText.version` 使用其正版本 `lastServerVersion`,不额外加一;恢复版本不额外设置上限,`NextVersion` 溢出 fatal 保留为理论兜底。
+- 无正版本候选时恢复为空;下一条服务端 clip 版本为 1。
+- 恢复窗口内只收集 snapshot;合法 clip 不参与选举,进入独立有界恢复队列。
+- 每用户 snapshot 预算只统计候选 `snapshot.payload` 的 UTF-8 字节数总和;上限为 `snapshot_total_bytes`,达到上限后拒绝新的 snapshot 并保持已有候选不变。元数据开销不占用该预算,由在线连接数量约束。
+- 恢复队列容量为 `recovery_clip_queue_capacity`;队列满时关闭相应连接,避免内存无界增长;连接断开则丢弃其已排队 clip。
+- 恢复窗口结束后,先根据 snapshot 选举 winner 并恢复最新值,再按到达顺序串行处理恢复队列中的 clip。
+- 窗口结束后广播 welcome 或恢复后的最新值,客户端按 hash 与版本去重。
+- 错过窗口的慢设备之后仍可发送 clip;该 clip 按到达顺序获得新版本并覆盖当前最新值。最后写入者胜,服务端不尝试识别或拒绝“逻辑上更旧”的 clip。
+
+服务端重启后的完整链路:
+
+1. 服务端停机前广播 `bye` 并以 `1001` 关闭连接。
+2. 客户端识别服务端维护,使用无状态 token 直接重试 WebSocket。
+3. 服务端重启后 token secret 与 tokenVersion 未变,token 仍可验证。
+4. 客户端 hello 上报 snapshot。
+5. 3 秒窗口选举 winner。
+6. 服务端恢复最新值并继续同步。
+
+### 6.3 慢连接
+
+每个连接有独立有界发送 Channel:
+
+- 默认容量 16 条消息。
+- `TryWrite` 失败即判定慢连接。
+- 立即调用 `CancelConnection`,不等待 drain,不补发应用层 error,也不写 close frame。
+- 发送循环观测取消后直接退出;`OperationCanceledException` 与非取消异常都汇入统一清理路径。
+- 该场景使用 abort/dispose 释放底层 socket,不执行 graceful WebSocket close 握手。
+- 客户端重连后通过 welcome 拿最新值,不补发中间消息。
+
+慢连接不能阻塞用户单消费者,也不能影响同用户其他连接。
+
+## 7. 优雅停机
+
+函数:
+
+- `BroadcastByeAsync(reason)`:向所有连接发送 `bye`。
+- `ShutdownAsync(CancellationToken)`:关闭连接、停止任务、等待短暂收尾。
+
+流程:
+
+1. 收到 SIGTERM、Ctrl+C 或服务停止请求。
+2. 停止接受新连接。
+3. 广播:
+
+```json
+{
+ "type": "bye",
+ "reason": "server_shutdown"
+}
+```
+
+4. 以 close code `1001` 关闭所有连接。
+5. 等待最多 2 秒,让 close frame 尽量发出。
+6. 取消所有连接 CTS。
+7. 清理 UserHub 与后台任务。
+8. 进程退出,由系统服务管理器重启。
+
+## 8. 日志与安全
+
+### 8.1 结构化日志
+
+函数:
+
+- `LogSecurityEvent()`:记录登录、认证失败、限流与禁用用户事件。
+- `RedactSensitive(value)`:统一脱敏。
+
+规则:
+
+- 使用 `ILogger` 结构化字段。
+- 密码绝不记录。
+- token 只可记录短前缀,默认不记录。
+- clip payload 与 hash 不记录;clip 事件只记 version、字节数、encrypted、来源设备。
+- Authorization header 不进入访问日志。
+
+关键事件:
+
+| 事件 | 字段 |
+|---|---|
+| login | username, ip, success, reason |
+| connect | username, clientId, connectionId |
+| disconnect | username, clientId, reason, durationMs |
+| clip | username, version, bytes, fromClientId, encrypted |
+| reject | username, code, bytes |
+| server_stop | reason, activeConnections |
+
+### 8.2 传输与输入安全
+
+- 生产只允许 HTTPS/WSS。
+- TLS 最低 1.2,推荐 1.3。
+- 不启用 CORS。
+- 不设置 Cookie,无 CSRF 面。
+- 登录请求体上限 16KB。
+- WebSocket 完整帧与文本字段分别限额。
+- JSON 深度限制为 3。
+- 协议消息只接受契约定义字段;重复字段与未知字段拒绝。若未来新增可选字段,必须提升或明确协议兼容策略。
+
+## 9. 性能目标
+
+| 指标 | v1 目标 |
+|---|---:|
+| 基础进程内存 | < 50 MB |
+| 100 个空闲连接内存增量 | < 20 MB |
+| 1KB 文本 LAN 广播 p95 | < 30 ms |
+| 512KB 文本 LAN 广播 p95 | < 250 ms |
+| 空闲 CPU | 接近 0%,心跳扫描除外 |
+| 冷启动时间 | < 2 s |
+| 服务端重启恢复窗口 | 3 s |
+
+设计依据:
+
+- 无数据库连接池与定时磁盘 IO。
+- 每用户一个 Channel 单消费者,避免锁与异步持锁。
+- 每次广播只做一次 UTF-8 序列化。
+- 每连接发送队列有界,内存上限可预测。
+- 空闲连接只保留上下文、发送 Channel 与心跳扫描状态。
+
+## 10. 测试计划
+
+### 10.1 纯单元测试
+
+重点函数:
+
+- `HashPassword`、`VerifyPassword`、`NeedsRehash`
+- `SignToken`、`VerifyToken`、tokenVersion 撤销
+- Token 重复字段、未知字段、非法数字与非法范围
+- 全局 `nextTokenVersion` 水位递增、删除后重建同名用户、溢出 fail-fast
+- CLI PID 单实例锁:活跃进程互斥、陈旧 PID 与锁文件残留处理
+- `TryConsumeLoginLimit`
+- 登录限流成功重置用户名窗口但不重置 IP 窗口
+- 登录限流最大 key 数:先清理过期项,仍满时拒绝新 key
+- `TryAcquireClipToken`
+- `ValidateClipMessage`
+- `CheckFrameSize`、`CheckPayloadSize`
+- `UserHub.TryDuplicate`、`RememberId`
+- 重复 `id` 不消耗令牌桶,重复 ACK 仍受有界发送队列约束
+- `NextVersion`、`WithVersion`
+- `SelectSnapshotWinner`,包括 `lastServerVersion=0` 不参与、无正版本候选恢复为空、同版本时间与 clientId 平局
+
+认证测试注入假哈希器,避免 Argon2id 拖慢常规单元测试。
+
+### 10.2 CI 集成测试:内存 WebSocket
+
+使用 `CreateSocketPair()` 建立内存连接,快速稳定覆盖:
+
+- 登录成功与失败。
+- 升级前认证失败不建立 WebSocket。
+- 子协议不匹配拒绝。
+- hello 超时。
+- 登录后仅记录 `NeedsRehash` warning,不重写用户文件。
+- 部分设备重连时的 snapshot 选举,包括 `lastServerVersion=0` 被忽略与无正版本候选恢复为空。
+- 恢复窗口从进程启动时间全局起算;窗口内 clip 排队、队列容量、payload 字节预算与窗口后处理顺序。
+- 恢复窗口结束后 snapshot 仅校验后丢弃。
+- 两客户端同用户广播与发送方 ACK;相同 `clientId` 的其他连接仍收到广播,仅发送方连接被排除。
+- 不同用户隔离。
+- 幂等 ID。
+- 慢连接队列满立即取消且不影响同用户其他接收方。
+- 停机 bye 与 1001。
+- 重启 snapshot 选举。
+
+### 10.3 本地网络测试:真实 localhost TCP
+
+标记:`Category=NetworkIntegration`,CI 默认跳过。
+
+```bash
+dotnet test --filter Category=NetworkIntegration
+```
+
+使用 `StartTestServer()` 启动完整 Kestrel,覆盖:
+
+- TLS 证书与 WSS。
+- HTTP 升级。
+- 随机端口绑定。
+- 真实帧分片。
+- 登录、建连、发送文本、另一客户端接收。
+- 服务端重启后 token 直连重连与 snapshot 恢复。
+
+### 10.4 契约测试与压测
+
+- 服务端维护典型 JSON 样本,约束三端协议字段与行为。
+- 契约样本必须覆盖 JSON 深度 3、重复字段、未知字段、非法数字与非法 UTF-8。
+- 独立 `TextCascade.Server.Benchmark` 项目执行压测,不进入生产产物。
+- 压测场景包括空闲连接、1000 并发连接、小文本广播、512KB 文本广播与慢消费者。
+
+## 11. 客户端适配要求
+
+客户端需要实现:
+
+1. `POST /api/v1/login` 获取 token。
+2. Authorization header + `textcascade.v1` 子协议建立 WebSocket。
+3. 在登录响应返回的 `helloTimeoutSeconds` 内发送 hello,并携带本地 snapshot。
+4. 应用层 ping/pong。
+5. clip 发送、ACK、接收。
+6. 保存服务端 version,重连时上报 `lastServerVersion`。
+7. 收到相同 hash 或相同版本时不写剪贴板;收到更晚到达的旧 clip 仍可能覆盖本地文本。
+8. `1001` 后按服务端维护重连;token 未过期时优先直接重连。
+9. `401`、token 过期或 tokenVersion 失效时重新 HTTP 登录。
+10. 重连退避建议:1s、2s、5s、10s、30s、60s,之后固定 60s;收到 1001 时初期退避更温和。
+
+保留客户端原有能力:
+
+- 本地剪贴板监听。
+- hash 去重。
+- 远端写入后的本地事件抑制。
+- 密码派生 AES-GCM 加密。
+- 密码安全保存与自动登录。
+
+## 12. 实施里程碑
+
+### M1:协议骨架
+
+- 配置加载与 fail-fast 校验。
+- `users.json` 与 CLI。
+- 登录端点。
+- HMAC token。
+- WebSocket 升级认证与子协议协商。
+- hello/welcome。
+- 文本广播与 ACK。
+- 纯单元测试与内存集成测试。
+
+### M2:可靠性
+
+- UserHub Channel 单消费者。
+- 有界发送队列与立即取消策略。
+- 幂等 ID。
+- 服务端版本号与不可变最新值。
+- 应用层心跳。
+- 统一 CTS 清理。
+
+### M3:恢复与真实网络
+
+- 优雅停机 bye/1001。
+- 3 秒 snapshot 恢复窗口。
+- snapshot 总字节数与恢复 clip 队列容量。
+- token 过期与 tokenVersion 撤销测试。
+- 本地真实 TCP/TLS 集成测试。
+- 重启后多端收敛测试。
+
+### M4:生产化
+
+- Kestrel TLS。
+- 结构化日志与脱敏。
+- 登录与消息限流。
+- 框架依赖单文件发布。
+- 独立 benchmark。
+- 部署文档与 Runtime 版本校验。
+
+## 13. 版本与发布
+
+- 产品版本采用 SemVer 2.0.0,从 `0.1.0` 开始,写入 `TextCascade.Server.csproj` 的 `Version`。
+- `protocolVersion` 只表示线协议版本,当前为 `1`,与产品版本独立演进。
+- 目标框架为 `net10.0`;目标机必须预装兼容的 .NET 10 Runtime。
+- 发布命令:`dotnet publish TextCascade.Server.csproj -c Release -p:PublishSingleFile=true`。
+- 本地编译命令:`dotnet build TextCascade.Server.csproj -c Release`。
+
+## 14. 已关闭问题
+
+| 问题 | 结论 |
+|---|---|
+| 最大文本默认值 | 512KB |
+| 最新值磁盘持久化 | 不做,保持极简 |
+| 用户配置热加载 | 不做,重启生效 |
+| token 生命周期 | 长期 token + tokenVersion 撤销 |
+| 删除后重建用户 | 全局 nextTokenVersion 水位 |
+| metrics | v1 不启用 endpoint |
+| 协议包 | 三端手写,服务端契约测试约束 |
+| 关闭码 | 应用层 error + 标准 close code;不发送 1013/4408 |
From eb0440ee55c9df5b4f62047d2af49ea19ba01d5d Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Sat, 22 Aug 2026 15:38:04 +0800
Subject: [PATCH 07/32] Fix sliding window login limiter queue cleanup
---
TextCascade.Server.Tests/LoginLimiterTests.cs | 71 +++++++++++++++++++
TextCascade.Server/Core.cs | 66 ++++++++++++++---
2 files changed, 127 insertions(+), 10 deletions(-)
diff --git a/TextCascade.Server.Tests/LoginLimiterTests.cs b/TextCascade.Server.Tests/LoginLimiterTests.cs
index 4e1f818..c9ccfd8 100644
--- a/TextCascade.Server.Tests/LoginLimiterTests.cs
+++ b/TextCascade.Server.Tests/LoginLimiterTests.cs
@@ -71,4 +71,75 @@ public void ExpiredEntriesAreLazilyRemoved()
Assert.False(limiter.TryConsumeLoginLimit("1.1.1.1", "alice", now, config));
Assert.True(limiter.TryConsumeLoginLimit("1.1.1.1", "alice", later, config));
}
+
+ [Fact]
+ public void RemoveExpiredKeepsUnexpiredRetryAfterOlderEntry()
+ {
+ var limiter = new SlidingWindowLoginLimiter();
+ var t0 = DateTimeOffset.FromUnixTimeSeconds(1760000000);
+ var config = TextCascade.Server.Config.CreateDefaultConfig() with
+ {
+ RateLimit = new RateLimitConfig(3, 3, 10, 10, 2),
+ };
+
+ // Consume twice at t0
+ Assert.True(limiter.TryConsumeLoginLimit("1.1.1.1", "alice", t0, config));
+ Assert.True(limiter.TryConsumeLoginLimit("1.1.1.1", "alice", t0, config));
+
+ // Consume once at t0 + 70s (this purges entries <= t0 + 10s, but window here is at t0+70s, cutoff t0+10s)
+ var t70 = t0.AddSeconds(70);
+ Assert.True(limiter.TryConsumeLoginLimit("1.1.1.1", "alice", t70, config));
+
+ // In a non-monotonic test or another check: at t0 + 65s, let's test specific cutoff
+ // Let's test by direct consumption with another limiter to follow spec exactly:
+ var limiter2 = new SlidingWindowLoginLimiter();
+ // limit=3; 同一 IP 在 t0 消费两次,t0+70s 消费一次。
+ Assert.True(limiter2.TryConsumeLoginLimit("1.1.1.1", "alice", t0, config));
+ Assert.True(limiter2.TryConsumeLoginLimit("1.1.1.1", "alice", t0, config));
+ // Enqueue at t0+70s
+ Assert.True(limiter2.TryConsumeLoginLimit("1.1.1.1", "alice", t0.AddSeconds(70), config));
+ // At t0+70s, the 2 t0 records expired, only 1 record (at 70s) remains.
+ // So we can consume 2 more times at t0+70s:
+ Assert.True(limiter2.TryConsumeLoginLimit("1.1.1.1", "alice", t0.AddSeconds(70), config));
+ Assert.True(limiter2.TryConsumeLoginLimit("1.1.1.1", "alice", t0.AddSeconds(70), config));
+ // Now 3 records at t0+70s exist -> next should fail
+ Assert.False(limiter2.TryConsumeLoginLimit("1.1.1.1", "alice", t0.AddSeconds(70), config));
+ }
+
+ [Fact]
+ public void RemoveExpiredDeletesKeyOnlyWhenQueueIsEmpty()
+ {
+ var limiter = new SlidingWindowLoginLimiter();
+ var t0 = DateTimeOffset.FromUnixTimeSeconds(1760000000);
+
+ limiter.EnqueueForTest("ip:1.1.1.1", t0);
+ limiter.EnqueueForTest("ip:1.1.1.1", t0.AddSeconds(40));
+
+ // Cutoff at t0 + 10s: first item expired, second item unexpired
+ limiter.RemoveExpiredForTest(t0.AddSeconds(10));
+ Assert.True(limiter.HasWindowKey("ip:1.1.1.1"));
+ Assert.Equal(1, limiter.GetWindowCount("ip:1.1.1.1"));
+
+ // Cutoff at t0 + 50s: all expired
+ limiter.RemoveExpiredForTest(t0.AddSeconds(50));
+ Assert.False(limiter.HasWindowKey("ip:1.1.1.1"));
+ }
+
+ [Fact]
+ public void LoginLimitsMustBePositive()
+ {
+ var baseConfig = TextCascade.Server.Config.CreateDefaultConfig() with { TokenSecret = new byte[32] };
+
+ var configZeroIp = baseConfig with
+ {
+ RateLimit = baseConfig.RateLimit with { LoginIpPerMinute = 0 },
+ };
+ Assert.Throws(() => TextCascade.Server.Config.ValidateConfig(configZeroIp));
+
+ var configZeroUser = baseConfig with
+ {
+ RateLimit = baseConfig.RateLimit with { LoginUserPerMinute = 0 },
+ };
+ Assert.Throws(() => TextCascade.Server.Config.ValidateConfig(configZeroUser));
+ }
}
diff --git a/TextCascade.Server/Core.cs b/TextCascade.Server/Core.cs
index ff61597..25828d0 100644
--- a/TextCascade.Server/Core.cs
+++ b/TextCascade.Server/Core.cs
@@ -29,6 +29,42 @@ public void ResetUserLoginLimit(string username)
}
}
+ internal int GetWindowCount(string key)
+ {
+ lock (gate)
+ {
+ return windows.TryGetValue(key, out var queue) ? queue.Count : 0;
+ }
+ }
+
+ internal bool HasWindowKey(string key)
+ {
+ lock (gate)
+ {
+ return windows.ContainsKey(key);
+ }
+ }
+
+ internal void RemoveExpiredForTest(DateTimeOffset cutoff)
+ {
+ lock (gate)
+ {
+ RemoveExpired(cutoff);
+ }
+ }
+
+ internal void EnqueueForTest(string key, DateTimeOffset timestamp)
+ {
+ lock (gate)
+ {
+ if (!windows.TryGetValue(key, out var queue))
+ {
+ windows[key] = queue = new Queue();
+ }
+ queue.Enqueue(timestamp);
+ }
+ }
+
private bool TryConsume(string key, int limit, DateTimeOffset nowUtc, int maxKeys, bool allowNewKey)
{
var cutoff = nowUtc.AddMinutes(-1);
@@ -57,22 +93,32 @@ private bool TryConsume(string key, int limit, DateTimeOffset nowUtc, int maxKey
}
queue.Enqueue(nowUtc);
- if (queue.Count == 0)
- {
- windows.Remove(key);
- }
-
return true;
}
private void RemoveExpired(DateTimeOffset cutoff)
{
- var stale = windows.Where(pair => pair.Value.Count == 0 || pair.Value.Peek() <= cutoff)
- .Select(pair => pair.Key)
- .ToList();
- foreach (var key in stale)
+ List? emptyKeys = null;
+ foreach (var pair in windows)
{
- windows.Remove(key);
+ var queue = pair.Value;
+ while (queue.Count > 0 && queue.Peek() <= cutoff)
+ {
+ queue.Dequeue();
+ }
+
+ if (queue.Count == 0)
+ {
+ (emptyKeys ??= new List()).Add(pair.Key);
+ }
+ }
+
+ if (emptyKeys is not null)
+ {
+ foreach (var key in emptyKeys)
+ {
+ windows.Remove(key);
+ }
}
}
}
From 31b864643fbc8e8d3d1e8075fccc27734c6d6334 Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Sat, 22 Aug 2026 15:41:14 +0800
Subject: [PATCH 08/32] Add constant-time dummy hash verification for
non-existent users
---
.../AuthServiceTimingTests.cs | 179 ++++++++++++++++++
TextCascade.Server/AuthService.cs | 16 +-
TextCascade.Server/SyncServer.cs | 6 +
3 files changed, 191 insertions(+), 10 deletions(-)
create mode 100644 TextCascade.Server.Tests/AuthServiceTimingTests.cs
diff --git a/TextCascade.Server.Tests/AuthServiceTimingTests.cs b/TextCascade.Server.Tests/AuthServiceTimingTests.cs
new file mode 100644
index 0000000..4fb6092
--- /dev/null
+++ b/TextCascade.Server.Tests/AuthServiceTimingTests.cs
@@ -0,0 +1,179 @@
+using System.IO.Pipelines;
+using System.Text;
+using System.Text.Json;
+using Isopoh.Cryptography.Argon2;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using TextCascade.Server;
+
+namespace TextCascade.Server.Tests;
+
+public class AuthServiceTimingTests
+{
+ private sealed class RecordingHasher : IPasswordHasher
+ {
+ public List<(string Password, Argon2Config Config)> HashCalls { get; } = new();
+ public List<(string Password, string EncodedHash)> VerifyCalls { get; } = new();
+
+ public string DummyHashReturn { get; set; } = "$argon2id$v=19$m=19456,t=2,p=1$dummy";
+
+ public string Hash(string password, Argon2Config config)
+ {
+ HashCalls.Add((password, config));
+ return DummyHashReturn;
+ }
+
+ public bool Verify(string password, string encodedHash)
+ {
+ VerifyCalls.Add((password, encodedHash));
+ return string.Equals(password, "correct-password", StringComparison.Ordinal)
+ && string.Equals(encodedHash, "valid-hash", StringComparison.Ordinal);
+ }
+
+ public bool NeedsRehash(string encodedHash, Argon2Config config) => false;
+ }
+
+ private sealed class TestLogger : ILogger
+ {
+ public List LoggedMessages { get; } = new();
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(
+ LogLevel logLevel,
+ EventId eventId,
+ TState state,
+ Exception? exception,
+ Func formatter)
+ {
+ LoggedMessages.Add(formatter(state, exception));
+ }
+ }
+
+ private static (DefaultHttpContext Context, MemoryStream ResponseBody) CreateHttpContext(string username, string password)
+ {
+ var context = new DefaultHttpContext();
+ var json = JsonSerializer.Serialize(new { username, password });
+ var bytes = Encoding.UTF8.GetBytes(json);
+ context.Request.Body = new MemoryStream(bytes);
+ context.Request.ContentLength = bytes.Length;
+ context.Request.ContentType = "application/json";
+
+ var responseBody = new MemoryStream();
+ context.Response.Body = responseBody;
+ return (context, responseBody);
+ }
+
+ [Fact]
+ public async Task LoginVerificationRunsForMissingUser()
+ {
+ var hasher = new RecordingHasher();
+ var config = TextCascade.Server.Config.CreateDefaultConfig() with { TokenSecret = new byte[32] };
+ var users = new UsersFile
+ {
+ Users = [new UserRecord("alice", "valid-hash", 1)],
+ NextTokenVersion = 2,
+ };
+ var tempState = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json");
+ try
+ {
+ var stateStore = new RuntimeStateStore(tempState);
+ var server = new SyncServer(config, users, stateStore, hasher, new SystemClock(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
+
+ Assert.Equal(server.LoginDummyHash, hasher.DummyHashReturn);
+
+ // 1. Missing user
+ var (missingContext, _) = CreateHttpContext("missing", "any-password");
+ await AuthService.HandleLoginAsync(missingContext, config, server);
+ Assert.Equal(401, missingContext.Response.StatusCode);
+ Assert.Single(hasher.VerifyCalls);
+ Assert.Equal("missing", "missing");
+ Assert.Equal(server.LoginDummyHash, hasher.VerifyCalls[0].EncodedHash);
+ Assert.Equal("any-password", hasher.VerifyCalls[0].Password);
+
+ // 2. Existing user alice with wrong password
+ var (aliceContext, _) = CreateHttpContext("alice", "wrong-password");
+ await AuthService.HandleLoginAsync(aliceContext, config, server);
+ Assert.Equal(401, aliceContext.Response.StatusCode);
+ Assert.Equal(2, hasher.VerifyCalls.Count);
+ Assert.Equal("valid-hash", hasher.VerifyCalls[1].EncodedHash);
+ Assert.Equal("wrong-password", hasher.VerifyCalls[1].Password);
+ }
+ finally
+ {
+ if (File.Exists(tempState)) File.Delete(tempState);
+ }
+ }
+
+ [Fact]
+ public void LoginDummyHashUsesConfiguredArgon2Parameters()
+ {
+ var hasher = new RecordingHasher();
+ var config = TextCascade.Server.Config.CreateDefaultConfig() with
+ {
+ TokenSecret = new byte[32],
+ Auth = new AuthConfig(30, "TEST_SECRET", 32768, 4, 2),
+ };
+ var users = new UsersFile();
+ var tempState = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json");
+ try
+ {
+ var stateStore = new RuntimeStateStore(tempState);
+ var server = new SyncServer(config, users, stateStore, hasher, new SystemClock(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
+
+ Assert.Single(hasher.HashCalls);
+ var call = hasher.HashCalls[0];
+ Assert.Equal("textcascade-login-timing-dummy", call.Password);
+ Assert.Equal(32768, call.Config.MemoryCost);
+ Assert.Equal(4, call.Config.TimeCost);
+ Assert.Equal(2, call.Config.Threads);
+ Assert.Equal(server.LoginDummyHash, hasher.DummyHashReturn);
+ }
+ finally
+ {
+ if (File.Exists(tempState)) File.Delete(tempState);
+ }
+ }
+
+ [Fact]
+ public async Task DisabledUserStillReturnsUnifiedInvalidCredentials()
+ {
+ var hasher = new RecordingHasher();
+ var config = TextCascade.Server.Config.CreateDefaultConfig() with { TokenSecret = new byte[32] };
+ var users = new UsersFile
+ {
+ Users = [new UserRecord("dave", "valid-hash", 1, Disabled: true)],
+ NextTokenVersion = 2,
+ };
+ var tempState = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json");
+ var logger = new TestLogger();
+ try
+ {
+ var stateStore = new RuntimeStateStore(tempState);
+ var server = new SyncServer(config, users, stateStore, hasher, new SystemClock(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
+
+ var (context, responseBody) = CreateHttpContext("dave", "correct-password");
+ await AuthService.HandleLoginAsync(context, config, server, logger);
+
+ Assert.Equal(401, context.Response.StatusCode);
+ var json = Encoding.UTF8.GetString(responseBody.ToArray());
+ Assert.Contains("invalid_credentials", json);
+
+ // Ensure logs do not mention "disabled"
+ Assert.NotEmpty(logger.LoggedMessages);
+ foreach (var log in logger.LoggedMessages)
+ {
+ Assert.DoesNotContain("reason=disabled", log, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("disabled", log, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+ finally
+ {
+ if (File.Exists(tempState)) File.Delete(tempState);
+ }
+ }
+}
+
+
diff --git a/TextCascade.Server/AuthService.cs b/TextCascade.Server/AuthService.cs
index d1688b8..da9304e 100644
--- a/TextCascade.Server/AuthService.cs
+++ b/TextCascade.Server/AuthService.cs
@@ -34,22 +34,18 @@ public static async Task HandleLoginAsync(HttpContext context, RuntimeConfig con
var userLookup = syncServer.UserLookup;
var found = userLookup.TryGetValue(request.Username, out var user);
- var passwordOk = found && user is not null && syncServer.Hasher.Verify(request.Password, user.PasswordHash);
- if (!passwordOk)
+ var passwordHash = found && user is not null
+ ? user.PasswordHash
+ : syncServer.LoginDummyHash;
+ var passwordOk = syncServer.Hasher.Verify(request.Password, passwordHash);
+ if (!found || !passwordOk || user is null || user.Disabled)
{
logger?.LogSecurityEvent("login", ("username", request.Username), ("ip", ip), ("success", false), ("reason", "invalid_credentials"));
await WriteError(context, 401, "invalid_credentials", "Invalid username or password.");
return;
}
- var authenticatedUser = user!;
- if (authenticatedUser.Disabled)
- {
- logger?.LogSecurityEvent("login", ("username", request.Username), ("ip", ip), ("success", false), ("reason", "disabled"));
- await WriteError(context, 401, "invalid_credentials", "Invalid username or password.");
- return;
- }
-
+ var authenticatedUser = user;
limiter.ResetUserLoginLimit(request.Username);
logger?.LogSecurityEvent("login", ("username", request.Username), ("ip", ip), ("success", true));
diff --git a/TextCascade.Server/SyncServer.cs b/TextCascade.Server/SyncServer.cs
index 1cb006c..44d058c 100644
--- a/TextCascade.Server/SyncServer.cs
+++ b/TextCascade.Server/SyncServer.cs
@@ -525,6 +525,7 @@ public sealed class SyncServer
private readonly IClock clock;
private readonly RuntimeStateStore runtimeStateStore;
private readonly IReadOnlyDictionary userLookup;
+ private readonly string loginDummyHash;
public UserRegistry Registry => registry;
public IPasswordHasher Hasher => hasher;
@@ -535,6 +536,7 @@ public sealed class SyncServer
public DateTimeOffset ProcessStartTime { get; }
public RuntimeConfig Config { get; }
public RuntimeStateStore RuntimeStateStore => runtimeStateStore;
+ internal string LoginDummyHash => loginDummyHash;
public SyncServer(
RuntimeConfig config,
@@ -551,6 +553,9 @@ public SyncServer(
this.clock = clock;
Logger = logger;
ProcessStartTime = clock.UtcNow;
+ loginDummyHash = hasher.Hash(
+ "textcascade-login-timing-dummy",
+ Cli.CreateArgon2Config(config));
}
public UserHub GetOrCreateHub(string username, RuntimeConfig runtimeConfig)
@@ -1080,3 +1085,4 @@ private static async Task SendSafeAsync(ConnectionContext connection, byte[] pay
internal sealed class FrameTooLargeException : Exception;
internal sealed record ReceivedMessage(WebSocketMessageType MessageType, byte[] Payload);
+
From 24b59630a6a7bc02315fe8f515185593fc1a4a2f Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Sat, 22 Aug 2026 15:41:59 +0800
Subject: [PATCH 09/32] Optimize SeenIdRing with hash map index and FIFO
circular queue
---
TextCascade.Server.Tests/ClipAndCoreTests.cs | 65 +++++++++++++++++
TextCascade.Server/Core.cs | 73 ++++++++++----------
2 files changed, 100 insertions(+), 38 deletions(-)
diff --git a/TextCascade.Server.Tests/ClipAndCoreTests.cs b/TextCascade.Server.Tests/ClipAndCoreTests.cs
index 6184282..2eac1ee 100644
--- a/TextCascade.Server.Tests/ClipAndCoreTests.cs
+++ b/TextCascade.Server.Tests/ClipAndCoreTests.cs
@@ -144,4 +144,69 @@ public void SelectSnapshotWinnerBreaksTiesByTimeThenClientId()
var winner2 = CoreLogic.SelectSnapshotWinner(new[] { a2, b2 });
Assert.Equal("pb", winner2!.Snapshot.Payload);
}
+
+ [Fact]
+ public void SeenIdRingMaintainsFifoEvictionAfterRepeatedIds()
+ {
+ var ring = new SeenIdRing(2);
+ var result1 = new LatestText("p1", 1, "h1", false, "c1", "n1", DateTimeOffset.UtcNow);
+ var result2 = new LatestText("p2", 2, "h2", false, "c2", "n2", DateTimeOffset.UtcNow);
+
+ ring.RememberId("a", result1);
+ ring.RememberId("b");
+ ring.RememberId("a", result2);
+ ring.RememberId("c");
+
+ // Eviction order: slot0 had "a"(overwritten by "a"), slot1 had "b"(overwritten by "c")
+ // When inserting "c" into slot 1, slot1 ("b") is evicted.
+ // When slot 0 is overwritten by "a", slot0 ("a") was updated.
+ // Let's check:
+ // slot 0: was "a" (result1), replaced by "a" (result2). entries["a"] = result2
+ // slot 1: was "b" (null), replaced by "c" (null). "b" was evicted!
+ // So "b" was evicted, "c" and "a" exist.
+ // If we now insert one more:
+ var result3 = new LatestText("p3", 3, "h3", false, "c3", "n3", DateTimeOffset.UtcNow);
+ ring.RememberId("d", result3); // overwrites slot 0 (which was "a"). Evicts "a"!
+ Assert.False(ring.TryGetResult("a", out _));
+ Assert.True(ring.TryGetResult("d", out var resD));
+ Assert.Equal(result3, resD);
+ }
+
+ [Fact]
+ public void SeenIdRingRepeatedIdKeepsLatestResultBeforeEviction()
+ {
+ var ring = new SeenIdRing(4);
+ var old = new LatestText("old", 1, "h", false, "c", "n", DateTimeOffset.UtcNow);
+ var latest = new LatestText("new", 2, "h", false, "c", "n", DateTimeOffset.UtcNow);
+
+ ring.RememberId("same", old);
+ ring.RememberId("same", latest);
+
+ Assert.True(ring.TryGetResult("same", out var actual));
+ Assert.Equal(latest, actual);
+ }
+
+ [Fact]
+ public void SeenIdRingEvictsOldestWhenFull()
+ {
+ var ring = new SeenIdRing(3);
+ ring.RememberId("a");
+ ring.RememberId("b");
+ ring.RememberId("c");
+ ring.RememberId("d");
+
+ Assert.False(ring.TryGetResult("a", out _));
+ Assert.True(ring.TryGetResult("b", out _));
+ Assert.True(ring.TryGetResult("c", out _));
+ Assert.True(ring.TryGetResult("d", out _));
+ }
+
+ [Fact]
+ public void SeenIdRingUsesOrdinalComparison()
+ {
+ var ring = new SeenIdRing(4);
+ ring.RememberId("A");
+ Assert.False(ring.TryDuplicate("a"));
+ Assert.True(ring.TryDuplicate("A"));
+ }
}
diff --git a/TextCascade.Server/Core.cs b/TextCascade.Server/Core.cs
index 25828d0..d06229e 100644
--- a/TextCascade.Server/Core.cs
+++ b/TextCascade.Server/Core.cs
@@ -168,27 +168,31 @@ public bool TryAcquire(DateTimeOffset nowUtc)
public sealed class SeenIdRing
{
private readonly object gate = new();
- private readonly string?[] ids;
- private readonly LatestText?[] results;
- private int next;
+ private readonly Dictionary entries;
+ private readonly string?[] insertionOrder;
+ private int nextInsertIndex;
+
+ public int Capacity { get; }
public SeenIdRing(int capacity)
{
- if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity));
- ids = new string?[capacity];
- results = new LatestText?[capacity];
+ if (capacity <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(capacity));
+ }
+
+ Capacity = capacity;
+ entries = new Dictionary(capacity, StringComparer.Ordinal);
+ insertionOrder = new string?[capacity];
}
public bool TryDuplicate(string id)
{
lock (gate)
{
- for (var i = 0; i < ids.Length; i++)
+ if (entries.ContainsKey(id))
{
- if (string.Equals(ids[i], id, StringComparison.Ordinal))
- {
- return true;
- }
+ return true;
}
RememberInternal(id, null);
@@ -200,17 +204,7 @@ public bool TryGetResult(string id, out LatestText? result)
{
lock (gate)
{
- for (var i = 0; i < ids.Length; i++)
- {
- if (string.Equals(ids[i], id, StringComparison.Ordinal))
- {
- result = results[i];
- return true;
- }
- }
-
- result = null;
- return false;
+ return entries.TryGetValue(id, out result);
}
}
@@ -228,30 +222,32 @@ public bool IsUnchangedDuplicate(string id, string payload, string hash, bool en
{
lock (gate)
{
- for (var index = 0; index < ids.Length; index++)
+ if (!entries.TryGetValue(id, out var remembered))
{
- if (!string.Equals(ids[index], id, StringComparison.Ordinal))
- {
- continue;
- }
-
- latest = results[index];
- return latest is not null
- && string.Equals(latest.Payload, payload, StringComparison.Ordinal)
- && string.Equals(latest.Hash, hash, StringComparison.Ordinal)
- && latest.Encrypted == encrypted;
+ latest = null;
+ return false;
}
- latest = null;
- return false;
+ latest = remembered;
+ return remembered is not null
+ && string.Equals(remembered.Payload, payload, StringComparison.Ordinal)
+ && string.Equals(remembered.Hash, hash, StringComparison.Ordinal)
+ && remembered.Encrypted == encrypted;
}
}
private void RememberInternal(string id, LatestText? result)
{
- ids[next] = id;
- results[next] = result;
- next = (next + 1) % ids.Length;
+ var evictedId = insertionOrder[nextInsertIndex];
+ if (evictedId is not null
+ && !string.Equals(evictedId, id, StringComparison.Ordinal))
+ {
+ entries.Remove(evictedId);
+ }
+
+ insertionOrder[nextInsertIndex] = id;
+ nextInsertIndex = (nextInsertIndex + 1) % insertionOrder.Length;
+ entries[id] = result;
}
}
@@ -286,3 +282,4 @@ public static LatestText WithVersion(LatestText latest, ulong next, DateTimeOffs
.FirstOrDefault();
}
}
+
From b7820093b03df1650e723dadf08e9efbaefde1c7 Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Sat, 22 Aug 2026 15:43:30 +0800
Subject: [PATCH 10/32] Use native .NET APIs for PEM certificate and key
loading
---
.../CertificateLoaderTests.cs | 143 ++++++++++++++++++
TextCascade.Server/ServerHost.cs | 71 +++------
2 files changed, 167 insertions(+), 47 deletions(-)
create mode 100644 TextCascade.Server.Tests/CertificateLoaderTests.cs
diff --git a/TextCascade.Server.Tests/CertificateLoaderTests.cs b/TextCascade.Server.Tests/CertificateLoaderTests.cs
new file mode 100644
index 0000000..649d6c6
--- /dev/null
+++ b/TextCascade.Server.Tests/CertificateLoaderTests.cs
@@ -0,0 +1,143 @@
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using TextCascade.Server;
+
+namespace TextCascade.Server.Tests;
+
+public class CertificateLoaderTests
+{
+ private static (string CertPem, string KeyPem) GenerateSelfSignedRsaPem()
+ {
+ using var rsa = RSA.Create(2048);
+ var request = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ var notBefore = DateTimeOffset.UtcNow.AddMinutes(-5);
+ var notAfter = DateTimeOffset.UtcNow.AddDays(1);
+ using var cert = request.CreateSelfSigned(notBefore, notAfter);
+
+ var certPem = cert.ExportCertificatePem();
+ var keyPem = rsa.ExportPkcs8PrivateKeyPem();
+ return (certPem, keyPem);
+ }
+
+ private static (string CertPem, string KeyPem) GenerateSelfSignedEcdsaPem()
+ {
+ using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+ var request = new CertificateRequest("CN=localhost", ecdsa, HashAlgorithmName.SHA256);
+ var notBefore = DateTimeOffset.UtcNow.AddMinutes(-5);
+ var notAfter = DateTimeOffset.UtcNow.AddDays(1);
+ using var cert = request.CreateSelfSigned(notBefore, notAfter);
+
+ var certPem = cert.ExportCertificatePem();
+ var keyPem = ecdsa.ExportPkcs8PrivateKeyPem();
+ return (certPem, keyPem);
+ }
+
+ [Fact]
+ public void LoadPemCertificateSupportsRsaCertAndSeparateKey()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var (certPem, keyPem) = GenerateSelfSignedRsaPem();
+ var certPath = Path.Combine(tempDir, "server.crt");
+ var keyPath = Path.Combine(tempDir, "server.key");
+ File.WriteAllText(certPath, certPem, Encoding.UTF8);
+ File.WriteAllText(keyPath, keyPem, Encoding.UTF8);
+
+ using var loaded = CertificateLoader.Load(certPath);
+ Assert.True(loaded.Certificate.HasPrivateKey);
+ Assert.NotEmpty(loaded.Chain);
+ Assert.Equal(loaded.Certificate.Thumbprint, loaded.Chain[0].Thumbprint);
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void LoadPemCertificateSupportsCombinedPem()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var (certPem, keyPem) = GenerateSelfSignedRsaPem();
+ var pemPath = Path.Combine(tempDir, "server.pem");
+ File.WriteAllText(pemPath, certPem + "\n" + keyPem, Encoding.UTF8);
+
+ using var loaded = CertificateLoader.Load(pemPath);
+ Assert.True(loaded.Certificate.HasPrivateKey);
+ Assert.NotEmpty(loaded.Chain);
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void LoadPemCertificateSupportsEcdsaCertAndSeparateKey()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var (certPem, keyPem) = GenerateSelfSignedEcdsaPem();
+ var certPath = Path.Combine(tempDir, "server.crt");
+ var keyPath = Path.Combine(tempDir, "server.key");
+ File.WriteAllText(certPath, certPem, Encoding.UTF8);
+ File.WriteAllText(keyPath, keyPem, Encoding.UTF8);
+
+ using var loaded = CertificateLoader.Load(certPath);
+ Assert.True(loaded.Certificate.HasPrivateKey);
+ Assert.NotEmpty(loaded.Chain);
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void LoadPemCertificateWrapsMissingPrivateKey()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var (certPem, _) = GenerateSelfSignedRsaPem();
+ var certPath = Path.Combine(tempDir, "server.pem");
+ File.WriteAllText(certPath, certPem, Encoding.UTF8);
+
+ var ex = Assert.Throws(() => CertificateLoader.Load(certPath));
+ Assert.NotNull(ex.InnerException);
+ Assert.Contains("Unable to load PEM certificate", ex.Message);
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void LoadPemCertificateRejectsNoCertificate()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var certPath = Path.Combine(tempDir, "empty.pem");
+ File.WriteAllText(certPath, "random garbage content", Encoding.UTF8);
+
+ var ex = Assert.Throws(() => CertificateLoader.Load(certPath));
+ Assert.Contains("Unable to load PEM certificate", ex.Message);
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+}
diff --git a/TextCascade.Server/ServerHost.cs b/TextCascade.Server/ServerHost.cs
index de1e5c5..7643a0b 100644
--- a/TextCascade.Server/ServerHost.cs
+++ b/TextCascade.Server/ServerHost.cs
@@ -2,7 +2,6 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
-using System.Text.RegularExpressions;
using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
@@ -155,64 +154,41 @@ private static LoadedCertificate LoadPemCertificate(string certificatePath)
var keyPath = File.Exists(Path.ChangeExtension(certificatePath, ".key"))
? Path.ChangeExtension(certificatePath, ".key")
: certificatePath;
+
var chain = new X509Certificate2Collection();
try
{
- var certificatePem = File.ReadAllText(certificatePath, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true));
- foreach (Match match in Regex.Matches(certificatePem, "-----BEGIN CERTIFICATE-----(?.*?)-----END CERTIFICATE-----", RegexOptions.Singleline))
- {
- var base64 = Regex.Replace(match.Groups["data"].Value, "\\s", string.Empty);
- var certificateBytes = Convert.FromBase64String(base64);
- chain.Add(X509CertificateLoader.LoadCertificate(certificateBytes));
- }
-
+ chain.ImportFromPemFile(certificatePath);
if (chain.Count == 0)
{
throw new InvalidOperationException("PEM file does not contain a certificate.");
}
- }
- catch (Exception exception)
- {
- DisposeChain(chain);
- throw new InvalidOperationException($"Unable to parse PEM certificate '{certificatePath}': {exception.Message}", exception);
- }
- string keyPem;
- try
- {
- keyPem = File.ReadAllText(keyPath, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true));
- }
- catch (Exception exception)
- {
- DisposeChain(chain);
- throw new InvalidOperationException($"Unable to read PEM private key '{keyPath}': {exception.Message}", exception);
- }
-
- try
- {
- using var rsa = RSA.Create();
- rsa.ImportFromPem(keyPem);
- var certificateWithKey = chain[0].CopyWithPrivateKey(rsa);
- chain[0].Dispose();
- chain[0] = certificateWithKey;
- return new LoadedCertificate(certificateWithKey, chain);
- }
- catch (Exception rsaException)
- {
- try
+ var certificateWithKey = X509Certificate2.CreateFromPemFile(certificatePath, keyPath);
+ var originalLeaf = chain.Cast().FirstOrDefault(c => c.Equals(certificateWithKey));
+ if (originalLeaf is not null)
{
- using var ecdsa = ECDsa.Create();
- ecdsa.ImportFromPem(keyPem);
- var certificateWithKey = chain[0].CopyWithPrivateKey(ecdsa);
- chain[0].Dispose();
- chain[0] = certificateWithKey;
- return new LoadedCertificate(certificateWithKey, chain);
+ var originalIndex = chain.IndexOf(originalLeaf);
+ chain[originalIndex] = certificateWithKey;
+ originalLeaf.Dispose();
}
- catch (Exception ecdsaException)
+ else
{
- DisposeChain(chain);
- throw new InvalidOperationException($"Unable to bind PEM private key. RSA: {rsaException.Message}; ECDSA: {ecdsaException.Message}", ecdsaException);
+ chain.Insert(0, certificateWithKey);
}
+
+ return new LoadedCertificate(certificateWithKey, chain);
+ }
+ catch (Exception exception) when (
+ exception is ArgumentException
+ or CryptographicException
+ or InvalidOperationException
+ or IOException)
+ {
+ DisposeChain(chain);
+ throw new InvalidOperationException(
+ $"Unable to load PEM certificate '{certificatePath}': {exception.Message}",
+ exception);
}
}
@@ -292,3 +268,4 @@ public void Dispose()
timer?.Dispose();
}
}
+
From 30362ae0f013b1870788bee53c3189ee0f8680cf Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Sat, 22 Aug 2026 15:45:10 +0800
Subject: [PATCH 11/32] Relocate CLI single-instance lock adjacent to
users.json file
---
.../SingleInstanceLockTests.cs | 124 ++++++++++++++++++
TextCascade.Server/Cli.cs | 64 +++++++--
2 files changed, 178 insertions(+), 10 deletions(-)
create mode 100644 TextCascade.Server.Tests/SingleInstanceLockTests.cs
diff --git a/TextCascade.Server.Tests/SingleInstanceLockTests.cs b/TextCascade.Server.Tests/SingleInstanceLockTests.cs
new file mode 100644
index 0000000..48672f4
--- /dev/null
+++ b/TextCascade.Server.Tests/SingleInstanceLockTests.cs
@@ -0,0 +1,124 @@
+using System.Diagnostics;
+using System.Globalization;
+using System.Text;
+using TextCascade.Server;
+
+namespace TextCascade.Server.Tests;
+
+public class SingleInstanceLockTests
+{
+ [Fact]
+ public void AcquireCreatesLockBesideUsersFile()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var usersFile = Path.Combine(tempDir, "users.json");
+ var lockPath = Cli.CreateLockPath(usersFile);
+
+ Assert.EndsWith("users.json.lock", lockPath, StringComparison.Ordinal);
+ Assert.Equal(tempDir, Path.GetDirectoryName(lockPath));
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void SecondProcessCannotAcquireSameUsersFileLock()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var lockPath = Path.Combine(tempDir, "users.json.lock");
+ using var handle1 = SingleInstanceLock.Acquire(lockPath, TimeSpan.FromMilliseconds(10));
+ Assert.NotNull(handle1);
+
+ using var handle2 = SingleInstanceLock.Acquire(lockPath, TimeSpan.FromMilliseconds(10));
+ Assert.Null(handle2);
+
+ handle1.Dispose();
+
+ using var handle3 = SingleInstanceLock.Acquire(lockPath, TimeSpan.FromMilliseconds(10));
+ Assert.NotNull(handle3);
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void StaleLockIsRecovered()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var lockPath = Path.Combine(tempDir, "users.json.lock");
+ // Find a non-existent PID (e.g. 999999)
+ var deadPid = 999999;
+ File.WriteAllText(lockPath, deadPid.ToString(CultureInfo.InvariantCulture), Encoding.UTF8);
+
+ using var handle = SingleInstanceLock.Acquire(lockPath, TimeSpan.FromMilliseconds(10));
+ Assert.NotNull(handle);
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void LiveProcessLockIsNotRecovered()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var lockPath = Path.Combine(tempDir, "users.json.lock");
+ var currentPid = Environment.ProcessId;
+ File.WriteAllText(lockPath, currentPid.ToString(CultureInfo.InvariantCulture), Encoding.UTF8);
+
+ using var handle = SingleInstanceLock.Acquire(lockPath, TimeSpan.FromMilliseconds(10));
+ Assert.Null(handle);
+ Assert.True(File.Exists(lockPath));
+ Assert.Equal(currentPid.ToString(CultureInfo.InvariantCulture), File.ReadAllText(lockPath).Trim());
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void AcquireRejectsPathWithoutDirectory()
+ {
+ Assert.Throws(() => SingleInstanceLock.Acquire("users.json.lock"));
+ }
+
+ [Fact]
+ public void DifferentUsersFilesCanLockIndependently()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var lockPath1 = Path.Combine(tempDir, "users1.json.lock");
+ var lockPath2 = Path.Combine(tempDir, "users2.json.lock");
+
+ using var handle1 = SingleInstanceLock.Acquire(lockPath1, TimeSpan.FromMilliseconds(10));
+ using var handle2 = SingleInstanceLock.Acquire(lockPath2, TimeSpan.FromMilliseconds(10));
+
+ Assert.NotNull(handle1);
+ Assert.NotNull(handle2);
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+}
diff --git a/TextCascade.Server/Cli.cs b/TextCascade.Server/Cli.cs
index 6c52614..18844b3 100644
--- a/TextCascade.Server/Cli.cs
+++ b/TextCascade.Server/Cli.cs
@@ -21,12 +21,6 @@ public static int RunCli(string[] args, IPasswordHasher? hasher = null)
}
hasher ??= new Argon2PasswordHasher();
- using var lockHandle = SingleInstanceLock.Acquire();
- if (lockHandle is null)
- {
- Console.Error.WriteLine("Another TextCascade CLI process is running.");
- return Error;
- }
var rest = args.Skip(1).ToArray();
if (!TryExtractConfigOption(ref rest, out var configPath))
@@ -51,7 +45,27 @@ public static int RunCli(string[] args, IPasswordHasher? hasher = null)
return Error;
}
- return rest switch
+ SingleInstanceLockHandle? lockHandle;
+ try
+ {
+ var lockPath = CreateLockPath(config.Files.UsersFile);
+ lockHandle = SingleInstanceLock.Acquire(lockPath);
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or DirectoryNotFoundException or ArgumentException or InvalidOperationException)
+ {
+ Console.Error.WriteLine($"Unable to acquire users file lock: {exception.Message}");
+ return Error;
+ }
+
+ using (lockHandle)
+ {
+ if (lockHandle is null)
+ {
+ Console.Error.WriteLine("Another TextCascade CLI process is running.");
+ return Error;
+ }
+
+ return rest switch
{
{ Length: > 0 } when rest[0] == "add" => CommandAddUser(rest, hasher, config),
{ Length: > 0 } when rest[0] == "passwd" => CommandPasswd(rest, hasher, config),
@@ -63,6 +77,20 @@ public static int RunCli(string[] args, IPasswordHasher? hasher = null)
{ Length: > 0 } when rest[0] == "hash" => CommandHashPassword(rest, hasher, config),
_ => PrintUsage(),
};
+ }
+ }
+
+ internal static string CreateLockPath(string usersFile)
+ {
+ var fullUsersPath = Path.GetFullPath(usersFile);
+ var directory = Path.GetDirectoryName(fullUsersPath);
+ if (string.IsNullOrEmpty(directory))
+ {
+ throw new InvalidOperationException("users.json path must include a parent directory.");
+ }
+
+ var fileName = Path.GetFileName(fullUsersPath);
+ return Path.Combine(directory, $"{fileName}.lock");
}
private static int PrintUsage()
@@ -381,10 +409,24 @@ public void Dispose()
public static class SingleInstanceLock
{
- public static SingleInstanceLockHandle? Acquire(TimeSpan? pollDelay = null)
+ public static SingleInstanceLockHandle? Acquire(string lockPath, TimeSpan? pollDelay = null)
{
- var directory = AppContext.BaseDirectory;
- var lockPath = Path.Combine(directory, ".textcascade-cli.lock");
+ if (string.IsNullOrWhiteSpace(lockPath))
+ {
+ throw new ArgumentException("Lock path must not be empty.", nameof(lockPath));
+ }
+
+ var directory = Path.GetDirectoryName(lockPath);
+ if (string.IsNullOrEmpty(directory))
+ {
+ throw new ArgumentException("Lock path must include a directory.", nameof(lockPath));
+ }
+
+ if (!Directory.Exists(directory))
+ {
+ throw new DirectoryNotFoundException($"Directory '{directory}' does not exist.");
+ }
+
var delay = pollDelay ?? TimeSpan.FromMilliseconds(100);
for (var attempt = 0; attempt < 3; attempt++)
@@ -469,3 +511,5 @@ private static bool IsProcessAlive(int pid)
}
}
}
+
+
From bedbca74628664e906b2c262bc599afb5061cb6b Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Sat, 22 Aug 2026 15:50:03 +0800
Subject: [PATCH 12/32] Split SyncServer into domain-oriented files and folders
---
.../Hosting/ConnectionHandler.cs | 269 ++++++
.../Hosting/HeartbeatScannerService.cs | 50 ++
TextCascade.Server/Hosting/SyncEndpoint.cs | 53 ++
TextCascade.Server/Hub/UserHub.cs | 355 ++++++++
TextCascade.Server/Hub/UserJobs.cs | 21 +
TextCascade.Server/Hub/UserRegistry.cs | 29 +
.../Models/ConnectionContext.cs | 26 +
.../Models/ConnectionStateBag.cs | 88 ++
TextCascade.Server/Models/ReceivedMessage.cs | 5 +
TextCascade.Server/ServerHost.cs | 47 -
TextCascade.Server/SyncServer.cs | 815 +-----------------
11 files changed, 897 insertions(+), 861 deletions(-)
create mode 100644 TextCascade.Server/Hosting/ConnectionHandler.cs
create mode 100644 TextCascade.Server/Hosting/HeartbeatScannerService.cs
create mode 100644 TextCascade.Server/Hosting/SyncEndpoint.cs
create mode 100644 TextCascade.Server/Hub/UserHub.cs
create mode 100644 TextCascade.Server/Hub/UserJobs.cs
create mode 100644 TextCascade.Server/Hub/UserRegistry.cs
create mode 100644 TextCascade.Server/Models/ConnectionContext.cs
create mode 100644 TextCascade.Server/Models/ConnectionStateBag.cs
create mode 100644 TextCascade.Server/Models/ReceivedMessage.cs
diff --git a/TextCascade.Server/Hosting/ConnectionHandler.cs b/TextCascade.Server/Hosting/ConnectionHandler.cs
new file mode 100644
index 0000000..de94052
--- /dev/null
+++ b/TextCascade.Server/Hosting/ConnectionHandler.cs
@@ -0,0 +1,269 @@
+using System.Globalization;
+using System.Net.WebSockets;
+using System.Text;
+using Microsoft.Extensions.Logging;
+
+namespace TextCascade.Server;
+
+public static class ConnectionHandler
+{
+ public static async Task RunAsync(ConnectionContext provisional, TokenPayload payload, RuntimeConfig config, SyncServer server)
+ {
+ server.RegisterPendingHello(provisional);
+ ClientHello hello;
+ try
+ {
+ var received = await ReceiveFrameAsync(provisional, config.Limits.MaxFrameBytes, provisional.State.Cts.Token);
+ if (received.MessageType == WebSocketMessageType.Close)
+ {
+ await provisional.Socket.CloseOutputAsync(
+ WebSocketCloseStatus.NormalClosure,
+ "client_closed",
+ CancellationToken.None);
+ server.CancelConnection(provisional, "closed");
+ return;
+ }
+
+ var parse = Protocol.ParseClientMessage(received.Payload, config);
+ if (!parse.IsSuccess || parse.Kind != MessageKind.Hello)
+ {
+ var error = Protocol.SerializeProtocolError(new ProtocolError(
+ ProtocolErrorCode.InvalidMessage,
+ "Expected a valid hello message.",
+ parse.Error?.ReferenceId));
+ await SendAndClosePreHelloAsync(
+ provisional,
+ error,
+ WebSocketCloseStatus.PolicyViolation,
+ "invalid_hello",
+ server);
+ return;
+ }
+
+ hello = (ClientHello)parse.Message!;
+ }
+ catch (FrameTooLargeException)
+ {
+ var error = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.FrameTooLarge, "frame_too_large", null));
+ await SendAndClosePreHelloAsync(provisional, error, WebSocketCloseStatus.MessageTooBig, "frame_too_large", server);
+ return;
+ }
+ catch (OperationCanceledException)
+ {
+ // Hello timeout is owned by the unified heartbeat scanner; here the socket was
+ // cancelled for another reason (e.g. shutdown). Fall through to unified cleanup.
+ server.CancelConnection(provisional, "cancelled");
+ return;
+ }
+ catch (WebSocketException)
+ {
+ server.CancelConnection(provisional, "socket_error");
+ return;
+ }
+
+ var hub = server.GetOrCreateHub(payload.Subject, config);
+ var connection = new ConnectionContext(
+ provisional.ConnectionId,
+ payload.Subject,
+ hello.ClientId,
+ hello.ClientName,
+ provisional.Socket,
+ hub,
+ config);
+ hub.AddConnection(connection);
+ server.Logger.LogSecurityEvent("connect",
+ ("username", connection.Username),
+ ("clientId", connection.ClientId),
+ ("connectionId", connection.ConnectionId));
+ connection.State.HelloReceived = true;
+ connection.State.LastSeen = DateTimeOffset.UtcNow;
+ server.UnregisterPendingHello(provisional);
+ if (!hub.TryWriteJob(new HelloJob(connection, hello)))
+ {
+ server.CancelConnection(connection, "user_loop_unavailable");
+ return;
+ }
+
+ var sendTask = ConnectionSendLoopAsync(connection);
+ var readTask = ReadLoopAsync(connection, config, server);
+ await Task.WhenAll(sendTask, readTask);
+ server.CancelConnection(connection, "disconnected");
+ }
+
+ private static async Task ReceiveFrameAsync(
+ ConnectionContext connection,
+ int maxBytes,
+ CancellationToken cancellationToken)
+ {
+ using var stream = new MemoryStream(Math.Min(maxBytes, 16 * 1024));
+ var buffer = new byte[Math.Min(maxBytes, 16 * 1024)];
+ while (true)
+ {
+ var received = await connection.Socket.ReceiveAsync(new ArraySegment(buffer), cancellationToken);
+ if (received.Count > maxBytes - stream.Length)
+ {
+ throw new FrameTooLargeException();
+ }
+
+ stream.Write(buffer, 0, received.Count);
+ if (received.EndOfMessage)
+ {
+ return new ReceivedMessage(received.MessageType, stream.ToArray());
+ }
+ }
+ }
+
+ private static async Task SendAndClosePreHelloAsync(
+ ConnectionContext connection,
+ byte[] error,
+ WebSocketCloseStatus status,
+ string reason,
+ SyncServer server)
+ {
+ try
+ {
+ if (connection.Socket.State == WebSocketState.Open)
+ {
+ await connection.Socket.SendAsync(error, WebSocketMessageType.Text, true, CancellationToken.None);
+ await connection.Socket.CloseAsync(status, reason, CancellationToken.None);
+ }
+ }
+ catch (Exception)
+ {
+ server.EnqueueImmediateClose(connection, "server_busy");
+ }
+ finally
+ {
+ server.CancelConnection(connection, reason);
+ }
+ }
+
+ private static async Task ReadLoopAsync(ConnectionContext connection, RuntimeConfig config, SyncServer server)
+ {
+ try
+ {
+ while (connection.State.Cts.IsCancellationRequested == false && connection.Socket.State == WebSocketState.Open)
+ {
+ ReceivedMessage received;
+ try
+ {
+ received = await ReceiveFrameAsync(connection, config.Limits.MaxFrameBytes, connection.State.Cts.Token);
+ }
+ catch (FrameTooLargeException)
+ {
+ var oversized = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.FrameTooLarge, "frame_too_large", null));
+ await SendSafeAsync(connection, oversized, server);
+ await Task.Delay(100, connection.State.Cts.Token);
+ if (connection.Socket.State == WebSocketState.Open)
+ {
+ await connection.Socket.CloseAsync(WebSocketCloseStatus.MessageTooBig, "frame_too_large", CancellationToken.None);
+ }
+ server.CancelConnection(connection, "frame_too_large");
+ break;
+ }
+
+ if (received.MessageType == WebSocketMessageType.Close)
+ {
+ try
+ {
+ await connection.Socket.CloseOutputAsync(
+ WebSocketCloseStatus.NormalClosure,
+ "client_closed",
+ CancellationToken.None);
+ }
+ catch (WebSocketException) { }
+ break;
+ }
+
+ if (!Protocol.CheckFrameSize(received.Payload.Length, config))
+ {
+ var error = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.FrameTooLarge, "frame_too_large", null));
+ await SendSafeAsync(connection, error, server);
+ await connection.Socket.CloseAsync(WebSocketCloseStatus.MessageTooBig, "frame_too_large", CancellationToken.None);
+ server.CancelConnection(connection, "frame_too_large");
+ break;
+ }
+
+ var parse = Protocol.ParseClientMessage(received.Payload, config);
+ if (!parse.IsSuccess)
+ {
+ server.Logger.LogSecurityEvent("reject",
+ ("username", connection.Username),
+ ("code", parse.Error?.CodeName ?? "invalid_message"),
+ ("bytes", received.Payload.Length));
+ var error = Protocol.SerializeProtocolError(parse.Error!);
+ await SendSafeAsync(connection, error, server);
+ continue;
+ }
+
+ switch (parse.Kind)
+ {
+ case MessageKind.Clip:
+ var clip = (ClientClip)parse.Message!;
+ var hub = connection.Hub;
+ if (hub is null)
+ {
+ server.CancelConnection(connection, "user_loop_unavailable");
+ break;
+ }
+
+ var decision = hub.ClassifyClip(clip, connection);
+ if (decision == RecoveryDecision.QueueFull)
+ {
+ server.CancelConnection(connection, "recovery_queue_full");
+ }
+ else if (decision == RecoveryDecision.ProcessNow
+ && !hub.TryWriteJob(new ClipJob(connection, clip)))
+ {
+ server.CancelConnection(connection, "user_loop_unavailable");
+ }
+ break;
+ case MessageKind.Pong:
+ if (!connection.State.TryTakePongAwaiting())
+ {
+ var unsolicitedPong = Protocol.SerializeProtocolError(new ProtocolError(
+ ProtocolErrorCode.InvalidMessage,
+ "Pong received without an outstanding ping.",
+ null));
+ await SendSafeAsync(connection, unsolicitedPong, server);
+ continue;
+ }
+
+ if (connection.Hub is null || !connection.Hub.TryWriteJob(new PongJob(connection, (ClientPong)parse.Message!)))
+ {
+ server.CancelConnection(connection, "user_loop_unavailable");
+ }
+ break;
+ }
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (WebSocketException) { }
+ }
+
+ private static async Task ConnectionSendLoopAsync(ConnectionContext connection)
+ {
+ try
+ {
+ await foreach (var payload in connection.State.SendQueue.Reader.ReadAllAsync(connection.State.Cts.Token))
+ {
+ await connection.Socket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, connection.State.Cts.Token);
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (WebSocketException) { }
+ }
+
+ private static async Task SendSafeAsync(ConnectionContext connection, byte[] payload, SyncServer server)
+ {
+ if (!connection.State.TryEnqueueSend(payload))
+ {
+ server.EnqueueImmediateClose(connection, "server_busy");
+ return;
+ }
+ }
+}
+
+
+
+internal sealed class FrameTooLargeException : Exception;
diff --git a/TextCascade.Server/Hosting/HeartbeatScannerService.cs b/TextCascade.Server/Hosting/HeartbeatScannerService.cs
new file mode 100644
index 0000000..2890fcf
--- /dev/null
+++ b/TextCascade.Server/Hosting/HeartbeatScannerService.cs
@@ -0,0 +1,50 @@
+using Microsoft.Extensions.Hosting;
+
+namespace TextCascade.Server;
+
+public sealed class HeartbeatScannerService : IHostedService, IDisposable
+{
+ private Timer? timer;
+
+ private readonly SyncServer syncServer;
+
+ public HeartbeatScannerService(SyncServer syncServer)
+ {
+ this.syncServer = syncServer;
+ }
+
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ timer = new Timer(Scan, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
+ return Task.CompletedTask;
+ }
+
+ private void Scan(object? state)
+ {
+ var now = DateTimeOffset.UtcNow;
+ syncServer.ScanHeartbeats(now);
+
+ var recoveryEnd = syncServer.ProcessStartTime.AddSeconds(
+ syncServer.Config.Limits.SnapshotWindowSeconds);
+ if (now < recoveryEnd)
+ {
+ return;
+ }
+
+ foreach (var pair in syncServer.Registry.All)
+ {
+ pair.Value.CloseRecoveryWindow(now);
+ }
+ }
+
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ timer?.Change(Timeout.Infinite, 0);
+ await syncServer.ShutdownAsync(TimeSpan.FromSeconds(2), DateTimeOffset.UtcNow);
+ }
+
+ public void Dispose()
+ {
+ timer?.Dispose();
+ }
+}
diff --git a/TextCascade.Server/Hosting/SyncEndpoint.cs b/TextCascade.Server/Hosting/SyncEndpoint.cs
new file mode 100644
index 0000000..366a7e7
--- /dev/null
+++ b/TextCascade.Server/Hosting/SyncEndpoint.cs
@@ -0,0 +1,53 @@
+using Microsoft.AspNetCore.Http;
+
+namespace TextCascade.Server;
+
+public static class SyncEndpoint
+{
+ public static async Task HandleAsync(HttpContext context, RuntimeConfig config, SyncServer server)
+ {
+ var tokenHeader = context.Request.Headers.Authorization.ToString();
+ if (!tokenHeader.StartsWith("Bearer ", StringComparison.Ordinal))
+ {
+ context.Response.StatusCode = 401;
+ return;
+ }
+
+ var compactToken = tokenHeader["Bearer ".Length..];
+ var now = DateTimeOffset.UtcNow;
+ var tokenService = new TokenService(config.TokenSecret!);
+ if (!tokenService.TryVerifyToken(compactToken, now, server.UserLookup, out var payload))
+ {
+ context.Response.StatusCode = 401;
+ return;
+ }
+
+ if (!context.WebSockets.IsWebSocketRequest)
+ {
+ context.Response.StatusCode = 400;
+ return;
+ }
+
+ var subProtocol = SelectSubProtocol(context.WebSockets.WebSocketRequestedProtocols);
+ if (subProtocol is null)
+ {
+ context.Response.StatusCode = 400;
+ return;
+ }
+
+ using var socket = await context.WebSockets.AcceptWebSocketAsync(subProtocol);
+ var connectionId = Guid.NewGuid().ToString("N");
+ var provisional = new ConnectionContext(connectionId, payload.Subject, "pending", "pending", socket, null!, config);
+ await ConnectionHandler.RunAsync(provisional, payload, config, server);
+ }
+
+ internal static string? SelectSubProtocol(IList requested)
+ {
+ foreach (var protocol in requested)
+ {
+ if (string.Equals(protocol, "textcascade.v1", StringComparison.Ordinal)) return protocol;
+ }
+ return null;
+ }
+}
+
diff --git a/TextCascade.Server/Hub/UserHub.cs b/TextCascade.Server/Hub/UserHub.cs
new file mode 100644
index 0000000..feed53c
--- /dev/null
+++ b/TextCascade.Server/Hub/UserHub.cs
@@ -0,0 +1,355 @@
+using System.Text;
+using System.Threading.Channels;
+using Microsoft.Extensions.Logging;
+
+namespace TextCascade.Server;
+
+public sealed class UserHub
+{
+ public string Username { get; }
+ public LatestText? Latest { get; private set; }
+ public ulong Version { get; private set; }
+ public Channel UserChannel { get; }
+ public TokenBucket ClipBucket { get; }
+ public SeenIdRing SeenIds { get; }
+ public DateTimeOffset ProcessStartTime { get; }
+ public DateTimeOffset LastActivityAt => new(new DateTime(Interlocked.Read(ref lastActivityTicks), DateTimeKind.Utc));
+
+ private readonly object connectionsGate = new();
+ private readonly List connections = new();
+ private readonly RuntimeConfig config;
+ private Task? userLoop;
+
+ private readonly object snapshotGate = new();
+ private readonly List snapshotCandidates = new();
+ private int snapshotBytes;
+ private readonly List recoveryQueue = new();
+ private bool recoveryWindowClosed;
+
+ private readonly SyncServer server;
+ private readonly RuntimeStateStore runtimeStateStore;
+ private long lastActivityTicks;
+
+ public UserHub(string username, RuntimeConfig config, DateTimeOffset processStart, SyncServer server, ulong initialVersion)
+ {
+ Username = username;
+ this.config = config;
+ this.server = server;
+ this.runtimeStateStore = server.RuntimeStateStore;
+ ProcessStartTime = processStart;
+ UserChannel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
+ ClipBucket = new TokenBucket(config.RateLimit.ClipBurst, config.RateLimit.ClipTokensPerSecond, processStart);
+ SeenIds = new SeenIdRing(config.Limits.SeenIdCapacity);
+ Version = initialVersion;
+ lastActivityTicks = processStart.UtcTicks;
+ }
+
+ public IReadOnlyList Connections
+ {
+ get { lock (connectionsGate) { return connections.ToArray(); } }
+ }
+
+ public bool IsEmpty
+ {
+ get { lock (connectionsGate) { return connections.Count == 0; } }
+ }
+
+ internal object ScanGate => connectionsGate;
+ internal List ConnectionList => connections;
+ internal RuntimeConfig Config => config;
+
+ public void AddConnection(ConnectionContext connection)
+ {
+ lock (connectionsGate) { connections.Add(connection); }
+ MarkActivity(DateTimeOffset.UtcNow);
+ var nowUtc = DateTimeOffset.UtcNow;
+ if (recoveryWindowClosed)
+ {
+ BroadcastToConnection(connection, Protocol.SerializeWelcome(Latest, config.Limits));
+ return;
+ }
+
+ EnsureRecoveryWindowClosed(nowUtc);
+ if (recoveryWindowClosed)
+ {
+ return;
+ }
+ }
+
+ public bool RemoveConnection(ConnectionContext connection)
+ {
+ bool removed;
+ lock (connectionsGate) { removed = connections.Remove(connection); }
+ if (removed)
+ {
+ MarkActivity(DateTimeOffset.UtcNow);
+ }
+
+ return removed;
+ }
+
+ public void StartIfIdle()
+ {
+ if (userLoop is null || userLoop.IsCompleted)
+ {
+ userLoop = Task.Run(async () =>
+ {
+ try { await RunUserLoopAsync(); }
+ catch (OperationCanceledException) { }
+ catch (Exception exception)
+ {
+ server.Logger.LogError(
+ exception,
+ "User loop failed; rebuilding hub. username={Username}",
+ Username);
+ server.RebuildHub(this);
+ }
+ });
+ }
+ }
+
+ public bool TryWriteJob(UserJob job) => UserChannel.Writer.TryWrite(job);
+
+ public async Task RunUserLoopAsync(CancellationToken cancellationToken = default)
+ {
+ var reader = UserChannel.Reader;
+ while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ while (reader.TryRead(out var job))
+ {
+ ProcessJob(job, DateTimeOffset.UtcNow);
+ }
+ }
+ }
+
+ private void ProcessJob(UserJob job, DateTimeOffset nowUtc)
+ {
+ switch (job)
+ {
+ case ClipJob clipJob:
+ ApplyClip(clipJob.Clip, clipJob.Sender, nowUtc);
+ break;
+ case PongJob pongJob:
+ pongJob.Connection.State.LastSeen = nowUtc;
+ break;
+ case HelloJob helloJob:
+ helloJob.Connection.State.HelloReceived = true;
+ if (helloJob.Hello.Snapshot is not null)
+ {
+ AcceptSnapshot(helloJob.Hello);
+ }
+ break;
+ case DisconnectJob disconnectJob:
+ server.CancelConnection(disconnectJob.Connection, disconnectJob.Reason);
+ break;
+ }
+ }
+
+ public void AcceptSnapshot(ClientHello hello)
+ {
+ lock (snapshotGate)
+ {
+ if (recoveryWindowClosed) return;
+ if (hello.Snapshot is null) return;
+ var bytes = Encoding.UTF8.GetByteCount(hello.Snapshot.Payload);
+ if (snapshotBytes + bytes > config.Limits.SnapshotTotalBytes) return;
+ snapshotCandidates.Add(hello);
+ snapshotBytes += bytes;
+ }
+ }
+
+ public RecoveryDecision ClassifyClip(ClientClip clip, ConnectionContext connection)
+ {
+ lock (snapshotGate)
+ {
+ if (recoveryWindowClosed)
+ {
+ return RecoveryDecision.ProcessNow;
+ }
+
+ if (recoveryQueue.Count >= config.Limits.RecoveryClipQueueCapacity)
+ {
+ return RecoveryDecision.QueueFull;
+ }
+
+ recoveryQueue.Add(new RecoveryClip(clip, connection));
+ return RecoveryDecision.Queued;
+ }
+ }
+
+ public void CloseRecoveryWindow(DateTimeOffset nowUtc)
+ {
+ List clips;
+ SnapshotWinner? winner;
+ lock (snapshotGate)
+ {
+ if (recoveryWindowClosed) return;
+ recoveryWindowClosed = true;
+ winner = CoreLogic.SelectSnapshotWinner(snapshotCandidates);
+ if (winner is not null)
+ {
+ var canRestoreLatest = winner.Version > Version
+ || (winner.Version == Version && Latest is null);
+ if (!canRestoreLatest)
+ {
+ winner = null;
+ }
+ else
+ {
+ if (winner.Version > Version)
+ {
+ runtimeStateStore.SaveVersion(Username, winner.Version);
+ }
+ Version = winner.Version;
+ Latest = new LatestText(winner.Snapshot.Payload, winner.Version, winner.Snapshot.Hash, winner.Snapshot.Encrypted, winner.ClientId, winner.ClientName, winner.Snapshot.LocalModifiedAtUtc);
+ }
+ }
+ clips = recoveryQueue.ToList();
+ recoveryQueue.Clear();
+ }
+
+ foreach (var recovery in clips)
+ {
+ if (recovery.Connection.State.IsClosed) continue;
+ ApplyClip(recovery.Clip, recovery.Connection, nowUtc);
+ }
+
+ BroadcastWelcome(nowUtc);
+
+ // Spec §6.2: empty hubs that survived until the recovery window closes are now removed.
+ server.Registry.RemoveIfEmpty(this, allowDuringRecovery: true);
+
+ MarkActivity(nowUtc);
+ }
+
+ private void BroadcastWelcome(DateTimeOffset nowUtc)
+ {
+ var bytes = Protocol.SerializeWelcome(Latest, config.Limits);
+ foreach (var connection in Connections)
+ {
+ if (!connection.State.TryEnqueueSend(bytes) && connection.State.MarkClosed())
+ {
+ connection.State.Cts.Cancel();
+ }
+ }
+ }
+
+ public bool IsRecoveryWindowOpen(DateTimeOffset nowUtc)
+ {
+ return !recoveryWindowClosed && nowUtc < ProcessStartTime.AddSeconds(config.Limits.SnapshotWindowSeconds);
+ }
+
+ public void EnsureRecoveryWindowClosed(DateTimeOffset nowUtc)
+ {
+ if (!recoveryWindowClosed && nowUtc >= ProcessStartTime.AddSeconds(config.Limits.SnapshotWindowSeconds))
+ {
+ CloseRecoveryWindow(nowUtc);
+ }
+ }
+
+ private void MarkActivity(DateTimeOffset nowUtc)
+ {
+ Interlocked.Exchange(ref lastActivityTicks, nowUtc.UtcTicks);
+ }
+
+ internal void MarkActivityForScan(DateTimeOffset nowUtc) => MarkActivity(nowUtc);
+
+ public void ApplyClip(ClientClip clip, ConnectionContext sender, DateTimeOffset nowUtc)
+ {
+ if (SeenIds.IsUnchangedDuplicate(clip.Id, clip.Payload, clip.Hash, clip.Encrypted, out var duplicateLatest))
+ {
+ var ackBytes = Protocol.SerializeClipAck(clip.Id, duplicateLatest ?? Latest ?? new LatestText(string.Empty, Version, string.Empty, false, sender.ClientId, sender.ClientName, nowUtc));
+ if (!sender.State.TryEnqueueSend(ackBytes) && sender.State.MarkClosed())
+ {
+ sender.State.Cts.Cancel();
+ }
+ return;
+ }
+
+ if (SeenIds.TryGetResult(clip.Id, out _))
+ {
+ server.Logger.LogWarning(
+ "Replacing reused clip id. username={Username} clipId={ClipId} clientId={ClientId} previousVersion={PreviousVersion}",
+ Username,
+ clip.Id,
+ sender.ClientId,
+ Version);
+ }
+
+ if (!ClipBucket.TryAcquire(nowUtc))
+ {
+ server.Logger.LogSecurityEvent("reject",
+ ("username", Username),
+ ("code", "rate_limited"),
+ ("bytes", Encoding.UTF8.GetByteCount(clip.Payload)));
+ var error = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.RateLimited, "Clip rate limited.", clip.Id));
+ if (!sender.State.TryEnqueueSend(error) && sender.State.MarkClosed())
+ {
+ sender.State.Cts.Cancel();
+ }
+ return;
+ }
+
+ var next = CoreLogic.NextVersion(Version);
+ runtimeStateStore.SaveVersion(Username, next);
+ Version = next;
+ var latest = new LatestText(clip.Payload, next, clip.Hash, clip.Encrypted, sender.ClientId, sender.ClientName, nowUtc);
+ Latest = latest;
+ SeenIds.RememberId(clip.Id, latest);
+ server.Logger.LogSecurityEvent("clip",
+ ("username", Username),
+ ("version", latest.Version),
+ ("clipId", clip.Id),
+ ("bytes", Encoding.UTF8.GetByteCount(clip.Payload)),
+ ("fromClientId", sender.ClientId),
+ ("encrypted", clip.Encrypted));
+
+ var broadcastBytes = Protocol.SerializeClip(clip.Id, latest);
+ var deliveries = new List();
+ foreach (var connection in Connections)
+ {
+ if (ReferenceEquals(connection, sender)) continue;
+ var queued = connection.State.TryEnqueueSend(broadcastBytes);
+ deliveries.Add($"{connection.ClientId}:{(queued ? "queued" : "full")}");
+ if (!queued && connection.State.MarkClosed())
+ {
+ connection.State.Cts.Cancel();
+ }
+ }
+
+ server.Logger.LogInformation(
+ "Clip broadcast. username={Username} version={Version} clipId={ClipId} recipients=[{Recipients}]",
+ Username,
+ next,
+ clip.Id,
+ string.Join(",", deliveries));
+
+ var ackBytesFinal = Protocol.SerializeClipAck(clip.Id, latest);
+ if (!sender.State.TryEnqueueSend(ackBytesFinal) && sender.State.MarkClosed())
+ {
+ sender.State.Cts.Cancel();
+ }
+ }
+
+ private static void BroadcastToConnection(ConnectionContext connection, byte[] payload)
+ {
+ if (!connection.State.TryEnqueueSend(payload) && connection.State.MarkClosed())
+ {
+ try { connection.State.Cts.Cancel(); } catch (Exception) { }
+ }
+ }
+
+ public void BroadcastAsync(byte[] payload)
+ {
+ foreach (var connection in Connections)
+ {
+ if (connection.State.IsClosed) continue;
+ if (!connection.State.TryEnqueueSend(payload) && connection.State.MarkClosed())
+ {
+ try { connection.State.Cts.Cancel(); } catch (Exception) { }
+ }
+ }
+ }
+}
+
+
diff --git a/TextCascade.Server/Hub/UserJobs.cs b/TextCascade.Server/Hub/UserJobs.cs
new file mode 100644
index 0000000..470bd10
--- /dev/null
+++ b/TextCascade.Server/Hub/UserJobs.cs
@@ -0,0 +1,21 @@
+namespace TextCascade.Server;
+
+public readonly record struct RecoveryClip(ClientClip Clip, ConnectionContext Connection);
+
+public enum RecoveryDecision
+{
+ Queued,
+ ProcessNow,
+ QueueFull,
+}
+
+public abstract record UserJob;
+
+public sealed record ClipJob(ConnectionContext Sender, ClientClip Clip) : UserJob;
+
+public sealed record HelloJob(ConnectionContext Connection, ClientHello Hello) : UserJob;
+
+public sealed record PongJob(ConnectionContext Connection, ClientPong Pong) : UserJob;
+
+public sealed record DisconnectJob(ConnectionContext Connection, string Reason) : UserJob;
+
diff --git a/TextCascade.Server/Hub/UserRegistry.cs b/TextCascade.Server/Hub/UserRegistry.cs
new file mode 100644
index 0000000..508fb8b
--- /dev/null
+++ b/TextCascade.Server/Hub/UserRegistry.cs
@@ -0,0 +1,29 @@
+using System.Collections.Concurrent;
+
+namespace TextCascade.Server;
+
+public sealed class UserRegistry
+{
+ private readonly ConcurrentDictionary hubs = new(StringComparer.Ordinal);
+ public IEnumerable> All => hubs;
+
+ public UserHub GetOrAdd(string username, Func factory)
+ {
+ return hubs.GetOrAdd(username, factory);
+ }
+
+ public bool TryGetValue(string username, out UserHub hub) => hubs.TryGetValue(username, out hub!);
+
+ public void RemoveIfEmpty(UserHub hub, bool allowDuringRecovery)
+ {
+ if (!hub.IsEmpty) return;
+ if (!allowDuringRecovery && hub.IsRecoveryWindowOpen(DateTimeOffset.UtcNow)) return;
+ hubs.TryRemove(hub.Username, out _);
+ }
+
+ public bool Remove(UserHub hub)
+ {
+ return hubs.TryRemove(new KeyValuePair(hub.Username, hub));
+ }
+}
+
diff --git a/TextCascade.Server/Models/ConnectionContext.cs b/TextCascade.Server/Models/ConnectionContext.cs
new file mode 100644
index 0000000..d9d6ede
--- /dev/null
+++ b/TextCascade.Server/Models/ConnectionContext.cs
@@ -0,0 +1,26 @@
+using System.Net.WebSockets;
+
+namespace TextCascade.Server;
+
+public sealed class ConnectionContext
+{
+ public string ConnectionId { get; }
+ public string Username { get; }
+ public string ClientId { get; }
+ public string ClientName { get; }
+ public WebSocket Socket { get; }
+ public UserHub? Hub { get; internal set; }
+ public ConnectionStateBag State { get; }
+
+ public ConnectionContext(string connectionId, string username, string clientId, string clientName, WebSocket socket, UserHub? hub, RuntimeConfig config)
+ {
+ ConnectionId = connectionId;
+ Username = username;
+ ClientId = clientId;
+ ClientName = clientName;
+ Socket = socket;
+ Hub = hub;
+ State = new ConnectionStateBag(config);
+ }
+}
+
diff --git a/TextCascade.Server/Models/ConnectionStateBag.cs b/TextCascade.Server/Models/ConnectionStateBag.cs
new file mode 100644
index 0000000..f35acaf
--- /dev/null
+++ b/TextCascade.Server/Models/ConnectionStateBag.cs
@@ -0,0 +1,88 @@
+using System.Threading.Channels;
+
+namespace TextCascade.Server;
+
+public sealed class ConnectionStateBag
+{
+ private readonly object gate = new();
+ private DateTimeOffset lastSeen;
+ private DateTimeOffset lastPingAt;
+ private bool closed;
+ private bool helloTimeoutStarted;
+ private bool pongAwaited;
+ public Channel SendQueue { get; }
+ public CancellationTokenSource Cts { get; }
+ public bool HelloReceived { get; internal set; }
+ public DateTimeOffset? HelloDeadline { get; internal set; }
+
+ public DateTimeOffset LastSeen
+ {
+ get { lock (gate) { return lastSeen; } }
+ internal set { lock (gate) { lastSeen = value; } }
+ }
+
+ public DateTimeOffset LastPingAt
+ {
+ get { lock (gate) { return lastPingAt; } }
+ internal set { lock (gate) { lastPingAt = value; } }
+ }
+
+ public void MarkPingAwaitingPong()
+ {
+ lock (gate) { pongAwaited = true; }
+ }
+
+ public bool TryTakePongAwaiting()
+ {
+ lock (gate)
+ {
+ if (!pongAwaited) return false;
+ pongAwaited = false;
+ return true;
+ }
+ }
+
+ public bool IsClosed
+ {
+ get { lock (gate) { return closed; } }
+ }
+
+ public bool MarkClosed()
+ {
+ lock (gate)
+ {
+ if (closed) return false;
+ closed = true;
+ return true;
+ }
+ }
+
+ public bool TryStartHelloTimeout()
+ {
+ lock (gate)
+ {
+ if (helloTimeoutStarted || closed)
+ {
+ return false;
+ }
+
+ helloTimeoutStarted = true;
+ return true;
+ }
+ }
+
+ public ConnectionStateBag(RuntimeConfig config)
+ {
+ lastSeen = DateTimeOffset.UtcNow;
+ lastPingAt = lastSeen;
+ SendQueue = Channel.CreateBounded(config.Limits.SendQueueCapacity);
+ Cts = new CancellationTokenSource();
+ HelloDeadline = DateTimeOffset.UtcNow.AddSeconds(config.Limits.HelloTimeoutSeconds);
+ }
+
+ public bool TryEnqueueSend(byte[] payload)
+ {
+ return SendQueue.Writer.TryWrite(payload);
+ }
+}
+
diff --git a/TextCascade.Server/Models/ReceivedMessage.cs b/TextCascade.Server/Models/ReceivedMessage.cs
new file mode 100644
index 0000000..04e844f
--- /dev/null
+++ b/TextCascade.Server/Models/ReceivedMessage.cs
@@ -0,0 +1,5 @@
+using System.Net.WebSockets;
+
+namespace TextCascade.Server;
+
+internal sealed record ReceivedMessage(WebSocketMessageType MessageType, byte[] Payload);
diff --git a/TextCascade.Server/ServerHost.cs b/TextCascade.Server/ServerHost.cs
index 7643a0b..a76c782 100644
--- a/TextCascade.Server/ServerHost.cs
+++ b/TextCascade.Server/ServerHost.cs
@@ -222,50 +222,3 @@ public void Dispose()
}
}
-public sealed class HeartbeatScannerService : IHostedService, IDisposable
-{
- private Timer? timer;
-
- private readonly SyncServer syncServer;
-
- public HeartbeatScannerService(SyncServer syncServer)
- {
- this.syncServer = syncServer;
- }
-
- public Task StartAsync(CancellationToken cancellationToken)
- {
- timer = new Timer(Scan, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
- return Task.CompletedTask;
- }
-
- private void Scan(object? state)
- {
- var now = DateTimeOffset.UtcNow;
- syncServer.ScanHeartbeats(now);
-
- var recoveryEnd = syncServer.ProcessStartTime.AddSeconds(
- syncServer.Config.Limits.SnapshotWindowSeconds);
- if (now < recoveryEnd)
- {
- return;
- }
-
- foreach (var pair in syncServer.Registry.All)
- {
- pair.Value.CloseRecoveryWindow(now);
- }
- }
-
- public async Task StopAsync(CancellationToken cancellationToken)
- {
- timer?.Change(Timeout.Infinite, 0);
- await syncServer.ShutdownAsync(TimeSpan.FromSeconds(2), DateTimeOffset.UtcNow);
- }
-
- public void Dispose()
- {
- timer?.Dispose();
- }
-}
-
diff --git a/TextCascade.Server/SyncServer.cs b/TextCascade.Server/SyncServer.cs
index 44d058c..cbb8759 100644
--- a/TextCascade.Server/SyncServer.cs
+++ b/TextCascade.Server/SyncServer.cs
@@ -1,511 +1,10 @@
-using System.Collections.Concurrent;
+using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text;
-using System.Threading.Channels;
-using System.Globalization;
-using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace TextCascade.Server;
-public sealed class ConnectionContext
-{
- public string ConnectionId { get; }
- public string Username { get; }
- public string ClientId { get; }
- public string ClientName { get; }
- public WebSocket Socket { get; }
- public UserHub? Hub { get; internal set; }
- public ConnectionStateBag State { get; }
-
- public ConnectionContext(string connectionId, string username, string clientId, string clientName, WebSocket socket, UserHub? hub, RuntimeConfig config)
- {
- ConnectionId = connectionId;
- Username = username;
- ClientId = clientId;
- ClientName = clientName;
- Socket = socket;
- Hub = hub;
- State = new ConnectionStateBag(config);
- }
-}
-
-public sealed class ConnectionStateBag
-{
- private readonly object gate = new();
- private DateTimeOffset lastSeen;
- private DateTimeOffset lastPingAt;
- private bool closed;
- private bool helloTimeoutStarted;
- private bool pongAwaited;
- public Channel SendQueue { get; }
- public CancellationTokenSource Cts { get; }
- public bool HelloReceived { get; internal set; }
- public DateTimeOffset? HelloDeadline { get; internal set; }
-
- public DateTimeOffset LastSeen
- {
- get { lock (gate) { return lastSeen; } }
- internal set { lock (gate) { lastSeen = value; } }
- }
-
- public DateTimeOffset LastPingAt
- {
- get { lock (gate) { return lastPingAt; } }
- internal set { lock (gate) { lastPingAt = value; } }
- }
-
- public void MarkPingAwaitingPong()
- {
- lock (gate) { pongAwaited = true; }
- }
-
- public bool TryTakePongAwaiting()
- {
- lock (gate)
- {
- if (!pongAwaited) return false;
- pongAwaited = false;
- return true;
- }
- }
-
- public bool IsClosed
- {
- get { lock (gate) { return closed; } }
- }
-
- public bool MarkClosed()
- {
- lock (gate)
- {
- if (closed) return false;
- closed = true;
- return true;
- }
- }
-
- public bool TryStartHelloTimeout()
- {
- lock (gate)
- {
- if (helloTimeoutStarted || closed)
- {
- return false;
- }
-
- helloTimeoutStarted = true;
- return true;
- }
- }
-
- public ConnectionStateBag(RuntimeConfig config)
- {
- lastSeen = DateTimeOffset.UtcNow;
- lastPingAt = lastSeen;
- SendQueue = Channel.CreateBounded(config.Limits.SendQueueCapacity);
- Cts = new CancellationTokenSource();
- HelloDeadline = DateTimeOffset.UtcNow.AddSeconds(config.Limits.HelloTimeoutSeconds);
- }
-
- public bool TryEnqueueSend(byte[] payload)
- {
- return SendQueue.Writer.TryWrite(payload);
- }
-}
-
-public sealed class UserHub
-{
- public string Username { get; }
- public LatestText? Latest { get; private set; }
- public ulong Version { get; private set; }
- public Channel UserChannel { get; }
- public TokenBucket ClipBucket { get; }
- public SeenIdRing SeenIds { get; }
- public DateTimeOffset ProcessStartTime { get; }
- public DateTimeOffset LastActivityAt => new(new DateTime(Interlocked.Read(ref lastActivityTicks), DateTimeKind.Utc));
-
- private readonly object connectionsGate = new();
- private readonly List connections = new();
- private readonly RuntimeConfig config;
- private Task? userLoop;
-
- private readonly object snapshotGate = new();
- private readonly List snapshotCandidates = new();
- private int snapshotBytes;
- private readonly List recoveryQueue = new();
- private bool recoveryWindowClosed;
-
- private readonly SyncServer server;
- private readonly RuntimeStateStore runtimeStateStore;
- private long lastActivityTicks;
-
- public UserHub(string username, RuntimeConfig config, DateTimeOffset processStart, SyncServer server, ulong initialVersion)
- {
- Username = username;
- this.config = config;
- this.server = server;
- this.runtimeStateStore = server.RuntimeStateStore;
- ProcessStartTime = processStart;
- UserChannel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
- ClipBucket = new TokenBucket(config.RateLimit.ClipBurst, config.RateLimit.ClipTokensPerSecond, processStart);
- SeenIds = new SeenIdRing(config.Limits.SeenIdCapacity);
- Version = initialVersion;
- lastActivityTicks = processStart.UtcTicks;
- }
-
- public IReadOnlyList Connections
- {
- get { lock (connectionsGate) { return connections.ToArray(); } }
- }
-
- public bool IsEmpty
- {
- get { lock (connectionsGate) { return connections.Count == 0; } }
- }
-
- internal object ScanGate => connectionsGate;
- internal List ConnectionList => connections;
- internal RuntimeConfig Config => config;
-
- public void AddConnection(ConnectionContext connection)
- {
- lock (connectionsGate) { connections.Add(connection); }
- MarkActivity(DateTimeOffset.UtcNow);
- var nowUtc = DateTimeOffset.UtcNow;
- if (recoveryWindowClosed)
- {
- BroadcastToConnection(connection, Protocol.SerializeWelcome(Latest, config.Limits));
- return;
- }
-
- EnsureRecoveryWindowClosed(nowUtc);
- if (recoveryWindowClosed)
- {
- return;
- }
- }
-
- public bool RemoveConnection(ConnectionContext connection)
- {
- bool removed;
- lock (connectionsGate) { removed = connections.Remove(connection); }
- if (removed)
- {
- MarkActivity(DateTimeOffset.UtcNow);
- }
-
- return removed;
- }
-
- public void StartIfIdle()
- {
- if (userLoop is null || userLoop.IsCompleted)
- {
- userLoop = Task.Run(async () =>
- {
- try { await RunUserLoopAsync(); }
- catch (OperationCanceledException) { }
- catch (Exception exception)
- {
- server.Logger.LogError(
- exception,
- "User loop failed; rebuilding hub. username={Username}",
- Username);
- server.RebuildHub(this);
- }
- });
- }
- }
-
- public bool TryWriteJob(UserJob job) => UserChannel.Writer.TryWrite(job);
-
- public async Task RunUserLoopAsync(CancellationToken cancellationToken = default)
- {
- var reader = UserChannel.Reader;
- while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
- {
- while (reader.TryRead(out var job))
- {
- ProcessJob(job, DateTimeOffset.UtcNow);
- }
- }
- }
-
- private void ProcessJob(UserJob job, DateTimeOffset nowUtc)
- {
- switch (job)
- {
- case ClipJob clipJob:
- ApplyClip(clipJob.Clip, clipJob.Sender, nowUtc);
- break;
- case PongJob pongJob:
- pongJob.Connection.State.LastSeen = nowUtc;
- break;
- case HelloJob helloJob:
- helloJob.Connection.State.HelloReceived = true;
- if (helloJob.Hello.Snapshot is not null)
- {
- AcceptSnapshot(helloJob.Hello);
- }
- break;
- case DisconnectJob disconnectJob:
- server.CancelConnection(disconnectJob.Connection, disconnectJob.Reason);
- break;
- }
- }
-
- public void AcceptSnapshot(ClientHello hello)
- {
- lock (snapshotGate)
- {
- if (recoveryWindowClosed) return;
- if (hello.Snapshot is null) return;
- var bytes = Encoding.UTF8.GetByteCount(hello.Snapshot.Payload);
- if (snapshotBytes + bytes > config.Limits.SnapshotTotalBytes) return;
- snapshotCandidates.Add(hello);
- snapshotBytes += bytes;
- }
- }
-
- public RecoveryDecision ClassifyClip(ClientClip clip, ConnectionContext connection)
- {
- lock (snapshotGate)
- {
- if (recoveryWindowClosed)
- {
- return RecoveryDecision.ProcessNow;
- }
-
- if (recoveryQueue.Count >= config.Limits.RecoveryClipQueueCapacity)
- {
- return RecoveryDecision.QueueFull;
- }
-
- recoveryQueue.Add(new RecoveryClip(clip, connection));
- return RecoveryDecision.Queued;
- }
- }
-
- public void CloseRecoveryWindow(DateTimeOffset nowUtc)
- {
- List clips;
- SnapshotWinner? winner;
- lock (snapshotGate)
- {
- if (recoveryWindowClosed) return;
- recoveryWindowClosed = true;
- winner = CoreLogic.SelectSnapshotWinner(snapshotCandidates);
- if (winner is not null)
- {
- var canRestoreLatest = winner.Version > Version
- || (winner.Version == Version && Latest is null);
- if (!canRestoreLatest)
- {
- winner = null;
- }
- else
- {
- if (winner.Version > Version)
- {
- runtimeStateStore.SaveVersion(Username, winner.Version);
- }
- Version = winner.Version;
- Latest = new LatestText(winner.Snapshot.Payload, winner.Version, winner.Snapshot.Hash, winner.Snapshot.Encrypted, winner.ClientId, winner.ClientName, winner.Snapshot.LocalModifiedAtUtc);
- }
- }
- clips = recoveryQueue.ToList();
- recoveryQueue.Clear();
- }
-
- foreach (var recovery in clips)
- {
- if (recovery.Connection.State.IsClosed) continue;
- ApplyClip(recovery.Clip, recovery.Connection, nowUtc);
- }
-
- BroadcastWelcome(nowUtc);
-
- // Spec §6.2: empty hubs that survived until the recovery window closes are now removed.
- server.Registry.RemoveIfEmpty(this, allowDuringRecovery: true);
-
- MarkActivity(nowUtc);
- }
-
- private void BroadcastWelcome(DateTimeOffset nowUtc)
- {
- var bytes = Protocol.SerializeWelcome(Latest, config.Limits);
- foreach (var connection in Connections)
- {
- if (!connection.State.TryEnqueueSend(bytes) && connection.State.MarkClosed())
- {
- connection.State.Cts.Cancel();
- }
- }
- }
-
- public bool IsRecoveryWindowOpen(DateTimeOffset nowUtc)
- {
- return !recoveryWindowClosed && nowUtc < ProcessStartTime.AddSeconds(config.Limits.SnapshotWindowSeconds);
- }
-
- public void EnsureRecoveryWindowClosed(DateTimeOffset nowUtc)
- {
- if (!recoveryWindowClosed && nowUtc >= ProcessStartTime.AddSeconds(config.Limits.SnapshotWindowSeconds))
- {
- CloseRecoveryWindow(nowUtc);
- }
- }
-
- private void MarkActivity(DateTimeOffset nowUtc)
- {
- Interlocked.Exchange(ref lastActivityTicks, nowUtc.UtcTicks);
- }
-
- internal void MarkActivityForScan(DateTimeOffset nowUtc) => MarkActivity(nowUtc);
-
- public void ApplyClip(ClientClip clip, ConnectionContext sender, DateTimeOffset nowUtc)
- {
- if (SeenIds.IsUnchangedDuplicate(clip.Id, clip.Payload, clip.Hash, clip.Encrypted, out var duplicateLatest))
- {
- var ackBytes = Protocol.SerializeClipAck(clip.Id, duplicateLatest ?? Latest ?? new LatestText(string.Empty, Version, string.Empty, false, sender.ClientId, sender.ClientName, nowUtc));
- if (!sender.State.TryEnqueueSend(ackBytes) && sender.State.MarkClosed())
- {
- sender.State.Cts.Cancel();
- }
- return;
- }
-
- if (SeenIds.TryGetResult(clip.Id, out _))
- {
- server.Logger.LogWarning(
- "Replacing reused clip id. username={Username} clipId={ClipId} clientId={ClientId} previousVersion={PreviousVersion}",
- Username,
- clip.Id,
- sender.ClientId,
- Version);
- }
-
- if (!ClipBucket.TryAcquire(nowUtc))
- {
- server.Logger.LogSecurityEvent("reject",
- ("username", Username),
- ("code", "rate_limited"),
- ("bytes", Encoding.UTF8.GetByteCount(clip.Payload)));
- var error = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.RateLimited, "Clip rate limited.", clip.Id));
- if (!sender.State.TryEnqueueSend(error) && sender.State.MarkClosed())
- {
- sender.State.Cts.Cancel();
- }
- return;
- }
-
- var next = CoreLogic.NextVersion(Version);
- runtimeStateStore.SaveVersion(Username, next);
- Version = next;
- var latest = new LatestText(clip.Payload, next, clip.Hash, clip.Encrypted, sender.ClientId, sender.ClientName, nowUtc);
- Latest = latest;
- SeenIds.RememberId(clip.Id, latest);
- server.Logger.LogSecurityEvent("clip",
- ("username", Username),
- ("version", latest.Version),
- ("clipId", clip.Id),
- ("bytes", Encoding.UTF8.GetByteCount(clip.Payload)),
- ("fromClientId", sender.ClientId),
- ("encrypted", clip.Encrypted));
-
- var broadcastBytes = Protocol.SerializeClip(clip.Id, latest);
- var deliveries = new List();
- foreach (var connection in Connections)
- {
- if (ReferenceEquals(connection, sender)) continue;
- var queued = connection.State.TryEnqueueSend(broadcastBytes);
- deliveries.Add($"{connection.ClientId}:{(queued ? "queued" : "full")}");
- if (!queued && connection.State.MarkClosed())
- {
- connection.State.Cts.Cancel();
- }
- }
-
- server.Logger.LogInformation(
- "Clip broadcast. username={Username} version={Version} clipId={ClipId} recipients=[{Recipients}]",
- Username,
- next,
- clip.Id,
- string.Join(",", deliveries));
-
- var ackBytesFinal = Protocol.SerializeClipAck(clip.Id, latest);
- if (!sender.State.TryEnqueueSend(ackBytesFinal) && sender.State.MarkClosed())
- {
- sender.State.Cts.Cancel();
- }
- }
-
- private static void BroadcastToConnection(ConnectionContext connection, byte[] payload)
- {
- if (!connection.State.TryEnqueueSend(payload) && connection.State.MarkClosed())
- {
- try { connection.State.Cts.Cancel(); } catch (Exception) { }
- }
- }
-
- public void BroadcastAsync(byte[] payload)
- {
- foreach (var connection in Connections)
- {
- if (connection.State.IsClosed) continue;
- if (!connection.State.TryEnqueueSend(payload) && connection.State.MarkClosed())
- {
- try { connection.State.Cts.Cancel(); } catch (Exception) { }
- }
- }
- }
-}
-
-public readonly record struct RecoveryClip(ClientClip Clip, ConnectionContext Connection);
-
-public enum RecoveryDecision
-{
- Queued,
- ProcessNow,
- QueueFull,
-}
-
-public abstract record UserJob;
-
-public sealed record ClipJob(ConnectionContext Sender, ClientClip Clip) : UserJob;
-
-public sealed record HelloJob(ConnectionContext Connection, ClientHello Hello) : UserJob;
-
-public sealed record PongJob(ConnectionContext Connection, ClientPong Pong) : UserJob;
-
-public sealed record DisconnectJob(ConnectionContext Connection, string Reason) : UserJob;
-
-public sealed class UserRegistry
-{
- private readonly ConcurrentDictionary hubs = new(StringComparer.Ordinal);
- public IEnumerable> All => hubs;
-
- public UserHub GetOrAdd(string username, Func factory)
- {
- return hubs.GetOrAdd(username, factory);
- }
-
- public bool TryGetValue(string username, out UserHub hub) => hubs.TryGetValue(username, out hub!);
-
- public void RemoveIfEmpty(UserHub hub, bool allowDuringRecovery)
- {
- if (!hub.IsEmpty) return;
- if (!allowDuringRecovery && hub.IsRecoveryWindowOpen(DateTimeOffset.UtcNow)) return;
- hubs.TryRemove(hub.Username, out _);
- }
-
- public bool Remove(UserHub hub)
- {
- return hubs.TryRemove(new KeyValuePair(hub.Username, hub));
- }
-}
-
public interface IClock
{
DateTimeOffset UtcNow { get; }
@@ -774,315 +273,3 @@ private static async Task CloseConnectionAsync(ConnectionContext connection, Web
}
}
-public static class SyncEndpoint
-{
- public static async Task HandleAsync(HttpContext context, RuntimeConfig config, SyncServer server)
- {
- var tokenHeader = context.Request.Headers.Authorization.ToString();
- if (!tokenHeader.StartsWith("Bearer ", StringComparison.Ordinal))
- {
- context.Response.StatusCode = 401;
- return;
- }
-
- var compactToken = tokenHeader["Bearer ".Length..];
- var now = DateTimeOffset.UtcNow;
- var tokenService = new TokenService(config.TokenSecret!);
- if (!tokenService.TryVerifyToken(compactToken, now, server.UserLookup, out var payload))
- {
- context.Response.StatusCode = 401;
- return;
- }
-
- if (!context.WebSockets.IsWebSocketRequest)
- {
- context.Response.StatusCode = 400;
- return;
- }
-
- var subProtocol = SelectSubProtocol(context.WebSockets.WebSocketRequestedProtocols);
- if (subProtocol is null)
- {
- context.Response.StatusCode = 400;
- return;
- }
-
- using var socket = await context.WebSockets.AcceptWebSocketAsync(subProtocol);
- var connectionId = Guid.NewGuid().ToString("N");
- var provisional = new ConnectionContext(connectionId, payload.Subject, "pending", "pending", socket, null!, config);
- await ConnectionHandler.RunAsync(provisional, payload, config, server);
- }
-
- internal static string? SelectSubProtocol(IList requested)
- {
- foreach (var protocol in requested)
- {
- if (string.Equals(protocol, "textcascade.v1", StringComparison.Ordinal)) return protocol;
- }
- return null;
- }
-}
-
-public static class ConnectionHandler
-{
- public static async Task RunAsync(ConnectionContext provisional, TokenPayload payload, RuntimeConfig config, SyncServer server)
- {
- server.RegisterPendingHello(provisional);
- ClientHello hello;
- try
- {
- var received = await ReceiveFrameAsync(provisional, config.Limits.MaxFrameBytes, provisional.State.Cts.Token);
- if (received.MessageType == WebSocketMessageType.Close)
- {
- await provisional.Socket.CloseOutputAsync(
- WebSocketCloseStatus.NormalClosure,
- "client_closed",
- CancellationToken.None);
- server.CancelConnection(provisional, "closed");
- return;
- }
-
- var parse = Protocol.ParseClientMessage(received.Payload, config);
- if (!parse.IsSuccess || parse.Kind != MessageKind.Hello)
- {
- var error = Protocol.SerializeProtocolError(new ProtocolError(
- ProtocolErrorCode.InvalidMessage,
- "Expected a valid hello message.",
- parse.Error?.ReferenceId));
- await SendAndClosePreHelloAsync(
- provisional,
- error,
- WebSocketCloseStatus.PolicyViolation,
- "invalid_hello",
- server);
- return;
- }
-
- hello = (ClientHello)parse.Message!;
- }
- catch (FrameTooLargeException)
- {
- var error = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.FrameTooLarge, "frame_too_large", null));
- await SendAndClosePreHelloAsync(provisional, error, WebSocketCloseStatus.MessageTooBig, "frame_too_large", server);
- return;
- }
- catch (OperationCanceledException)
- {
- // Hello timeout is owned by the unified heartbeat scanner; here the socket was
- // cancelled for another reason (e.g. shutdown). Fall through to unified cleanup.
- server.CancelConnection(provisional, "cancelled");
- return;
- }
- catch (WebSocketException)
- {
- server.CancelConnection(provisional, "socket_error");
- return;
- }
-
- var hub = server.GetOrCreateHub(payload.Subject, config);
- var connection = new ConnectionContext(
- provisional.ConnectionId,
- payload.Subject,
- hello.ClientId,
- hello.ClientName,
- provisional.Socket,
- hub,
- config);
- hub.AddConnection(connection);
- server.Logger.LogSecurityEvent("connect",
- ("username", connection.Username),
- ("clientId", connection.ClientId),
- ("connectionId", connection.ConnectionId));
- connection.State.HelloReceived = true;
- connection.State.LastSeen = DateTimeOffset.UtcNow;
- server.UnregisterPendingHello(provisional);
- if (!hub.TryWriteJob(new HelloJob(connection, hello)))
- {
- server.CancelConnection(connection, "user_loop_unavailable");
- return;
- }
-
- var sendTask = ConnectionSendLoopAsync(connection);
- var readTask = ReadLoopAsync(connection, config, server);
- await Task.WhenAll(sendTask, readTask);
- server.CancelConnection(connection, "disconnected");
- }
-
- private static async Task ReceiveFrameAsync(
- ConnectionContext connection,
- int maxBytes,
- CancellationToken cancellationToken)
- {
- using var stream = new MemoryStream(Math.Min(maxBytes, 16 * 1024));
- var buffer = new byte[Math.Min(maxBytes, 16 * 1024)];
- while (true)
- {
- var received = await connection.Socket.ReceiveAsync(new ArraySegment(buffer), cancellationToken);
- if (received.Count > maxBytes - stream.Length)
- {
- throw new FrameTooLargeException();
- }
-
- stream.Write(buffer, 0, received.Count);
- if (received.EndOfMessage)
- {
- return new ReceivedMessage(received.MessageType, stream.ToArray());
- }
- }
- }
-
- private static async Task SendAndClosePreHelloAsync(
- ConnectionContext connection,
- byte[] error,
- WebSocketCloseStatus status,
- string reason,
- SyncServer server)
- {
- try
- {
- if (connection.Socket.State == WebSocketState.Open)
- {
- await connection.Socket.SendAsync(error, WebSocketMessageType.Text, true, CancellationToken.None);
- await connection.Socket.CloseAsync(status, reason, CancellationToken.None);
- }
- }
- catch (Exception)
- {
- server.EnqueueImmediateClose(connection, "server_busy");
- }
- finally
- {
- server.CancelConnection(connection, reason);
- }
- }
-
- private static async Task ReadLoopAsync(ConnectionContext connection, RuntimeConfig config, SyncServer server)
- {
- try
- {
- while (connection.State.Cts.IsCancellationRequested == false && connection.Socket.State == WebSocketState.Open)
- {
- ReceivedMessage received;
- try
- {
- received = await ReceiveFrameAsync(connection, config.Limits.MaxFrameBytes, connection.State.Cts.Token);
- }
- catch (FrameTooLargeException)
- {
- var oversized = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.FrameTooLarge, "frame_too_large", null));
- await SendSafeAsync(connection, oversized, server);
- await Task.Delay(100, connection.State.Cts.Token);
- if (connection.Socket.State == WebSocketState.Open)
- {
- await connection.Socket.CloseAsync(WebSocketCloseStatus.MessageTooBig, "frame_too_large", CancellationToken.None);
- }
- server.CancelConnection(connection, "frame_too_large");
- break;
- }
-
- if (received.MessageType == WebSocketMessageType.Close)
- {
- try
- {
- await connection.Socket.CloseOutputAsync(
- WebSocketCloseStatus.NormalClosure,
- "client_closed",
- CancellationToken.None);
- }
- catch (WebSocketException) { }
- break;
- }
-
- if (!Protocol.CheckFrameSize(received.Payload.Length, config))
- {
- var error = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.FrameTooLarge, "frame_too_large", null));
- await SendSafeAsync(connection, error, server);
- await connection.Socket.CloseAsync(WebSocketCloseStatus.MessageTooBig, "frame_too_large", CancellationToken.None);
- server.CancelConnection(connection, "frame_too_large");
- break;
- }
-
- var parse = Protocol.ParseClientMessage(received.Payload, config);
- if (!parse.IsSuccess)
- {
- server.Logger.LogSecurityEvent("reject",
- ("username", connection.Username),
- ("code", parse.Error?.CodeName ?? "invalid_message"),
- ("bytes", received.Payload.Length));
- var error = Protocol.SerializeProtocolError(parse.Error!);
- await SendSafeAsync(connection, error, server);
- continue;
- }
-
- switch (parse.Kind)
- {
- case MessageKind.Clip:
- var clip = (ClientClip)parse.Message!;
- var hub = connection.Hub;
- if (hub is null)
- {
- server.CancelConnection(connection, "user_loop_unavailable");
- break;
- }
-
- var decision = hub.ClassifyClip(clip, connection);
- if (decision == RecoveryDecision.QueueFull)
- {
- server.CancelConnection(connection, "recovery_queue_full");
- }
- else if (decision == RecoveryDecision.ProcessNow
- && !hub.TryWriteJob(new ClipJob(connection, clip)))
- {
- server.CancelConnection(connection, "user_loop_unavailable");
- }
- break;
- case MessageKind.Pong:
- if (!connection.State.TryTakePongAwaiting())
- {
- var unsolicitedPong = Protocol.SerializeProtocolError(new ProtocolError(
- ProtocolErrorCode.InvalidMessage,
- "Pong received without an outstanding ping.",
- null));
- await SendSafeAsync(connection, unsolicitedPong, server);
- continue;
- }
-
- if (connection.Hub is null || !connection.Hub.TryWriteJob(new PongJob(connection, (ClientPong)parse.Message!)))
- {
- server.CancelConnection(connection, "user_loop_unavailable");
- }
- break;
- }
- }
- }
- catch (OperationCanceledException) { }
- catch (WebSocketException) { }
- }
-
- private static async Task ConnectionSendLoopAsync(ConnectionContext connection)
- {
- try
- {
- await foreach (var payload in connection.State.SendQueue.Reader.ReadAllAsync(connection.State.Cts.Token))
- {
- await connection.Socket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, connection.State.Cts.Token);
- }
- }
- catch (OperationCanceledException) { }
- catch (WebSocketException) { }
- }
-
- private static async Task SendSafeAsync(ConnectionContext connection, byte[] payload, SyncServer server)
- {
- if (!connection.State.TryEnqueueSend(payload))
- {
- server.EnqueueImmediateClose(connection, "server_busy");
- return;
- }
- }
-}
-
-internal sealed class FrameTooLargeException : Exception;
-
-internal sealed record ReceivedMessage(WebSocketMessageType MessageType, byte[] Payload);
-
From b8318736fbc71723462e90d3566a0039e8a072bd Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Sat, 22 Aug 2026 15:52:47 +0800
Subject: [PATCH 13/32] Decouple UserHub from SyncServer using
IConnectionCoordinator
---
.../RuntimeStateAndProtocolTests.cs | 6 +-
.../UserHubCoordinationTests.cs | 113 ++++++++++++++++++
.../Hub/IConnectionCoordinator.cs | 14 +++
TextCascade.Server/Hub/UserHub.cs | 27 +++--
TextCascade.Server/SyncServer.cs | 12 +-
5 files changed, 155 insertions(+), 17 deletions(-)
create mode 100644 TextCascade.Server.Tests/UserHubCoordinationTests.cs
create mode 100644 TextCascade.Server/Hub/IConnectionCoordinator.cs
diff --git a/TextCascade.Server.Tests/RuntimeStateAndProtocolTests.cs b/TextCascade.Server.Tests/RuntimeStateAndProtocolTests.cs
index b89fa76..2795346 100644
--- a/TextCascade.Server.Tests/RuntimeStateAndProtocolTests.cs
+++ b/TextCascade.Server.Tests/RuntimeStateAndProtocolTests.cs
@@ -85,7 +85,7 @@ public void RecoveryWindowRestoresSnapshotAtPersistedVersion()
new Argon2PasswordHasher(),
new SystemClock(),
NullLogger.Instance);
- var hub = new UserHub("alice", config, TestStartTime, server, 7UL);
+ var hub = new UserHub("alice", config, TestStartTime, server, server.RuntimeStateStore, 7UL);
var modified = DateTimeOffset.FromUnixTimeSeconds(1759999990);
hub.AcceptSnapshot(new ClientHello(
"client-a",
@@ -120,7 +120,7 @@ public void RecoveryWindowIgnoresStaleSnapshot()
new Argon2PasswordHasher(),
new SystemClock(),
NullLogger.Instance);
- var hub = new UserHub("alice", config, TestStartTime, server, 7UL);
+ var hub = new UserHub("alice", config, TestStartTime, server, server.RuntimeStateStore, 7UL);
hub.AcceptSnapshot(new ClientHello(
"client-a",
"Client A",
@@ -138,3 +138,5 @@ public void RecoveryWindowIgnoresStaleSnapshot()
}
}
}
+
+
diff --git a/TextCascade.Server.Tests/UserHubCoordinationTests.cs b/TextCascade.Server.Tests/UserHubCoordinationTests.cs
new file mode 100644
index 0000000..e40ccb8
--- /dev/null
+++ b/TextCascade.Server.Tests/UserHubCoordinationTests.cs
@@ -0,0 +1,113 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using TextCascade.Server;
+
+namespace TextCascade.Server.Tests;
+
+public class UserHubCoordinationTests
+{
+ private sealed class FakeCoordinator : IConnectionCoordinator
+ {
+ public ILogger Logger { get; set; } = NullLogger.Instance;
+ public List<(ConnectionContext Connection, string Reason)> Cancelled { get; } = new();
+ public List RebuiltHubs { get; } = new();
+ public List RemovedEmptyHubs { get; } = new();
+
+ public void CancelConnection(ConnectionContext connection, string reason)
+ {
+ Cancelled.Add((connection, reason));
+ }
+
+ public void RebuildHub(UserHub hub)
+ {
+ RebuiltHubs.Add(hub);
+ }
+
+ public void RemoveEmptyHubAfterRecovery(UserHub hub)
+ {
+ RemovedEmptyHubs.Add(hub);
+ }
+ }
+
+ [Fact]
+ public void UserHubDoesNotDependOnSyncServerConcreteType()
+ {
+ var fakeCoordinator = new FakeCoordinator();
+ var tempState = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json");
+ try
+ {
+ var stateStore = new RuntimeStateStore(tempState);
+ var config = TextCascade.Server.Config.CreateDefaultConfig() with
+ {
+ Limits = TextCascade.Server.Config.CreateDefaultConfig().Limits with { SnapshotWindowSeconds = 0 },
+ };
+
+ var hub = new UserHub(
+ "alice",
+ config,
+ DateTimeOffset.UtcNow,
+ fakeCoordinator,
+ stateStore,
+ 1UL);
+
+ var now = DateTimeOffset.UtcNow;
+ hub.CloseRecoveryWindow(now);
+
+ Assert.Single(fakeCoordinator.RemovedEmptyHubs);
+ Assert.Same(hub, fakeCoordinator.RemovedEmptyHubs[0]);
+ }
+ finally
+ {
+ if (File.Exists(tempState)) File.Delete(tempState);
+ }
+ }
+
+ [Fact]
+ public async Task UserLoopFailureNotifiesCoordinator()
+ {
+ var fakeCoordinator = new FakeCoordinator();
+ var tempState = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json");
+ try
+ {
+ var stateStore = new RuntimeStateStore(tempState);
+ var config = TextCascade.Server.Config.CreateDefaultConfig();
+
+ // Set initial version to ulong.MaxValue so next clip throws Version overflow
+ var hub = new UserHub(
+ "alice",
+ config,
+ DateTimeOffset.UtcNow,
+ fakeCoordinator,
+ stateStore,
+ ulong.MaxValue);
+
+ hub.StartIfIdle();
+
+ // Enqueue a clip job to trigger overflow
+ var dummySocket = new System.Net.WebSockets.ClientWebSocket();
+ var conn = new ConnectionContext("conn-1", "alice", "c1", "Client1", dummySocket, hub, config);
+ hub.AddConnection(conn);
+
+ // Close recovery window so clip can be applied
+ hub.CloseRecoveryWindow(DateTimeOffset.UtcNow.AddSeconds(10));
+
+ var clip = new ClientClip("id-overflow", "data", false, "hash");
+ hub.UserChannel.Writer.TryWrite(new ClipJob(conn, clip));
+
+ // Wait for user loop failure notification
+ var timeout = DateTime.UtcNow.AddSeconds(3);
+ while (fakeCoordinator.RebuiltHubs.Count == 0 && DateTime.UtcNow < timeout)
+ {
+ await Task.Delay(20);
+ }
+
+ Assert.Single(fakeCoordinator.RebuiltHubs);
+ Assert.Same(hub, fakeCoordinator.RebuiltHubs[0]);
+ }
+ finally
+ {
+ if (File.Exists(tempState)) File.Delete(tempState);
+ }
+ }
+}
+
diff --git a/TextCascade.Server/Hub/IConnectionCoordinator.cs b/TextCascade.Server/Hub/IConnectionCoordinator.cs
new file mode 100644
index 0000000..dd0cc60
--- /dev/null
+++ b/TextCascade.Server/Hub/IConnectionCoordinator.cs
@@ -0,0 +1,14 @@
+using Microsoft.Extensions.Logging;
+
+namespace TextCascade.Server;
+
+public interface IConnectionCoordinator
+{
+ ILogger Logger { get; }
+
+ void CancelConnection(ConnectionContext connection, string reason);
+
+ void RebuildHub(UserHub hub);
+
+ void RemoveEmptyHubAfterRecovery(UserHub hub);
+}
diff --git a/TextCascade.Server/Hub/UserHub.cs b/TextCascade.Server/Hub/UserHub.cs
index feed53c..877848c 100644
--- a/TextCascade.Server/Hub/UserHub.cs
+++ b/TextCascade.Server/Hub/UserHub.cs
@@ -1,4 +1,4 @@
-using System.Text;
+using System.Text;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
@@ -26,16 +26,16 @@ public sealed class UserHub
private readonly List recoveryQueue = new();
private bool recoveryWindowClosed;
- private readonly SyncServer server;
+ private readonly IConnectionCoordinator coordinator;
private readonly RuntimeStateStore runtimeStateStore;
private long lastActivityTicks;
- public UserHub(string username, RuntimeConfig config, DateTimeOffset processStart, SyncServer server, ulong initialVersion)
+ public UserHub(string username, RuntimeConfig config, DateTimeOffset processStart, IConnectionCoordinator coordinator, RuntimeStateStore runtimeStateStore, ulong initialVersion)
{
Username = username;
this.config = config;
- this.server = server;
- this.runtimeStateStore = server.RuntimeStateStore;
+ this.coordinator = coordinator;
+ this.runtimeStateStore = runtimeStateStore;
ProcessStartTime = processStart;
UserChannel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
ClipBucket = new TokenBucket(config.RateLimit.ClipBurst, config.RateLimit.ClipTokensPerSecond, processStart);
@@ -98,11 +98,11 @@ public void StartIfIdle()
catch (OperationCanceledException) { }
catch (Exception exception)
{
- server.Logger.LogError(
+ coordinator.Logger.LogError(
exception,
"User loop failed; rebuilding hub. username={Username}",
Username);
- server.RebuildHub(this);
+ coordinator.RebuildHub(this);
}
});
}
@@ -140,7 +140,7 @@ private void ProcessJob(UserJob job, DateTimeOffset nowUtc)
}
break;
case DisconnectJob disconnectJob:
- server.CancelConnection(disconnectJob.Connection, disconnectJob.Reason);
+ coordinator.CancelConnection(disconnectJob.Connection, disconnectJob.Reason);
break;
}
}
@@ -217,7 +217,7 @@ public void CloseRecoveryWindow(DateTimeOffset nowUtc)
BroadcastWelcome(nowUtc);
// Spec §6.2: empty hubs that survived until the recovery window closes are now removed.
- server.Registry.RemoveIfEmpty(this, allowDuringRecovery: true);
+ coordinator.RemoveEmptyHubAfterRecovery(this);
MarkActivity(nowUtc);
}
@@ -268,7 +268,7 @@ public void ApplyClip(ClientClip clip, ConnectionContext sender, DateTimeOffset
if (SeenIds.TryGetResult(clip.Id, out _))
{
- server.Logger.LogWarning(
+ coordinator.Logger.LogWarning(
"Replacing reused clip id. username={Username} clipId={ClipId} clientId={ClientId} previousVersion={PreviousVersion}",
Username,
clip.Id,
@@ -278,7 +278,7 @@ public void ApplyClip(ClientClip clip, ConnectionContext sender, DateTimeOffset
if (!ClipBucket.TryAcquire(nowUtc))
{
- server.Logger.LogSecurityEvent("reject",
+ coordinator.Logger.LogSecurityEvent("reject",
("username", Username),
("code", "rate_limited"),
("bytes", Encoding.UTF8.GetByteCount(clip.Payload)));
@@ -296,7 +296,7 @@ public void ApplyClip(ClientClip clip, ConnectionContext sender, DateTimeOffset
var latest = new LatestText(clip.Payload, next, clip.Hash, clip.Encrypted, sender.ClientId, sender.ClientName, nowUtc);
Latest = latest;
SeenIds.RememberId(clip.Id, latest);
- server.Logger.LogSecurityEvent("clip",
+ coordinator.Logger.LogSecurityEvent("clip",
("username", Username),
("version", latest.Version),
("clipId", clip.Id),
@@ -317,7 +317,7 @@ public void ApplyClip(ClientClip clip, ConnectionContext sender, DateTimeOffset
}
}
- server.Logger.LogInformation(
+ coordinator.Logger.LogInformation(
"Clip broadcast. username={Username} version={Version} clipId={ClipId} recipients=[{Recipients}]",
Username,
next,
@@ -353,3 +353,4 @@ public void BroadcastAsync(byte[] payload)
}
+
diff --git a/TextCascade.Server/SyncServer.cs b/TextCascade.Server/SyncServer.cs
index cbb8759..808feee 100644
--- a/TextCascade.Server/SyncServer.cs
+++ b/TextCascade.Server/SyncServer.cs
@@ -15,7 +15,7 @@ public sealed class SystemClock : IClock
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
-public sealed class SyncServer
+public sealed class SyncServer : IConnectionCoordinator
{
private readonly UserRegistry registry = new();
private readonly List pendingHellos = new();
@@ -57,10 +57,16 @@ public SyncServer(
Cli.CreateArgon2Config(config));
}
+ ILogger IConnectionCoordinator.Logger => Logger;
+
+ void IConnectionCoordinator.RebuildHub(UserHub hub) => RebuildHub(hub);
+
+ public void RemoveEmptyHubAfterRecovery(UserHub hub) => registry.RemoveIfEmpty(hub, allowDuringRecovery: true);
+
public UserHub GetOrCreateHub(string username, RuntimeConfig runtimeConfig)
{
var initialVersion = runtimeStateStore.GetVersion(username);
- var hub = registry.GetOrAdd(username, name => new UserHub(name, runtimeConfig, ProcessStartTime, this, initialVersion));
+ var hub = registry.GetOrAdd(username, name => new UserHub(name, runtimeConfig, ProcessStartTime, this, runtimeStateStore, initialVersion));
hub.StartIfIdle();
return hub;
}
@@ -273,3 +279,5 @@ private static async Task CloseConnectionAsync(ConnectionContext connection, Web
}
}
+
+
From 79dadd406f9e5777b957234f4704d96fa4c7ad8a Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Sat, 22 Aug 2026 16:01:16 +0800
Subject: [PATCH 14/32] Lock StartIfIdle and guard UserHub consumer loop
against re-entrancy
---
.../UserHubCoordinationTests.cs | 3 +-
.../UserLoopConcurrencyTests.cs | 80 +++++++++++++++++++
TextCascade.Server/Hub/UserHub.cs | 44 +++++++---
3 files changed, 116 insertions(+), 11 deletions(-)
create mode 100644 TextCascade.Server.Tests/UserLoopConcurrencyTests.cs
diff --git a/TextCascade.Server.Tests/UserHubCoordinationTests.cs b/TextCascade.Server.Tests/UserHubCoordinationTests.cs
index e40ccb8..5f6f7fd 100644
--- a/TextCascade.Server.Tests/UserHubCoordinationTests.cs
+++ b/TextCascade.Server.Tests/UserHubCoordinationTests.cs
@@ -81,7 +81,7 @@ public async Task UserLoopFailureNotifiesCoordinator()
stateStore,
ulong.MaxValue);
- hub.StartIfIdle();
+ _ = hub.StartIfIdle();
// Enqueue a clip job to trigger overflow
var dummySocket = new System.Net.WebSockets.ClientWebSocket();
@@ -111,3 +111,4 @@ public async Task UserLoopFailureNotifiesCoordinator()
}
}
+
diff --git a/TextCascade.Server.Tests/UserLoopConcurrencyTests.cs b/TextCascade.Server.Tests/UserLoopConcurrencyTests.cs
new file mode 100644
index 0000000..3982a30
--- /dev/null
+++ b/TextCascade.Server.Tests/UserLoopConcurrencyTests.cs
@@ -0,0 +1,80 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using TextCascade.Server;
+
+namespace TextCascade.Server.Tests;
+
+public class UserLoopConcurrencyTests
+{
+ private sealed class FakeCoordinator : IConnectionCoordinator
+ {
+ public ILogger Logger => NullLogger.Instance;
+ public void CancelConnection(ConnectionContext connection, string reason) { }
+ public void RebuildHub(UserHub hub) { }
+ public void RemoveEmptyHubAfterRecovery(UserHub hub) { }
+ }
+
+ [Fact]
+ public async Task StartIfIdleCreatesSingleTaskUnderConcurrency()
+ {
+ var tempState = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json");
+ try
+ {
+ var stateStore = new RuntimeStateStore(tempState);
+ var config = TextCascade.Server.Config.CreateDefaultConfig();
+ var hub = new UserHub("alice", config, DateTimeOffset.UtcNow, new FakeCoordinator(), stateStore, 1UL);
+
+ var startedTasks = new Task[100];
+ var runners = new Task[100];
+ for (var i = 0; i < 100; i++)
+ {
+ var idx = i;
+ runners[i] = Task.Factory.StartNew(() => { startedTasks[idx] = hub.StartIfIdle(); }, TaskCreationOptions.LongRunning);
+ }
+
+ await Task.WhenAll(runners);
+
+ var firstTask = startedTasks[0];
+ Assert.NotNull(firstTask);
+ for (var i = 1; i < startedTasks.Length; i++)
+ {
+ Assert.Same(firstTask, startedTasks[i]);
+ }
+
+ hub.UserChannel.Writer.Complete();
+ await firstTask;
+ }
+ finally
+ {
+ if (File.Exists(tempState)) File.Delete(tempState);
+ }
+ }
+
+ [Fact]
+ public async Task RunUserLoopRejectsConcurrentReader()
+ {
+ var tempState = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json");
+ using var cts = new CancellationTokenSource();
+ try
+ {
+ var stateStore = new RuntimeStateStore(tempState);
+ var config = TextCascade.Server.Config.CreateDefaultConfig();
+ var hub = new UserHub("alice", config, DateTimeOffset.UtcNow, new FakeCoordinator(), stateStore, 1UL);
+
+ var firstLoop = Task.Run(() => hub.RunUserLoopAsync(cts.Token));
+
+ // Wait a small amount to ensure first loop has claimed readerActive
+ await Task.Delay(50);
+
+ // Second concurrent reader must throw InvalidOperationException
+ await Assert.ThrowsAsync(() => hub.RunUserLoopAsync(cts.Token));
+
+ hub.UserChannel.Writer.Complete();
+ await firstLoop;
+ }
+ finally
+ {
+ if (File.Exists(tempState)) File.Delete(tempState);
+ }
+ }
+}
diff --git a/TextCascade.Server/Hub/UserHub.cs b/TextCascade.Server/Hub/UserHub.cs
index 877848c..1b8b132 100644
--- a/TextCascade.Server/Hub/UserHub.cs
+++ b/TextCascade.Server/Hub/UserHub.cs
@@ -19,6 +19,8 @@ public sealed class UserHub
private readonly List connections = new();
private readonly RuntimeConfig config;
private Task? userLoop;
+ private readonly object userLoopGate = new();
+ private int readerActive;
private readonly object snapshotGate = new();
private readonly List snapshotCandidates = new();
@@ -88,14 +90,24 @@ public bool RemoveConnection(ConnectionContext connection)
return removed;
}
- public void StartIfIdle()
+ public Task StartIfIdle()
{
- if (userLoop is null || userLoop.IsCompleted)
+ lock (userLoopGate)
{
+ if (userLoop is not null && !userLoop.IsCompleted)
+ {
+ return userLoop;
+ }
+
userLoop = Task.Run(async () =>
{
- try { await RunUserLoopAsync(); }
- catch (OperationCanceledException) { }
+ try
+ {
+ await RunUserLoopAsync();
+ }
+ catch (OperationCanceledException)
+ {
+ }
catch (Exception exception)
{
coordinator.Logger.LogError(
@@ -105,23 +117,34 @@ public void StartIfIdle()
coordinator.RebuildHub(this);
}
});
+ return userLoop;
}
}
-
public bool TryWriteJob(UserJob job) => UserChannel.Writer.TryWrite(job);
public async Task RunUserLoopAsync(CancellationToken cancellationToken = default)
{
- var reader = UserChannel.Reader;
- while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
+ if (Interlocked.CompareExchange(ref readerActive, 1, 0) != 0)
{
- while (reader.TryRead(out var job))
+ throw new InvalidOperationException("User loop is already running.");
+ }
+
+ try
+ {
+ var reader = UserChannel.Reader;
+ while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
- ProcessJob(job, DateTimeOffset.UtcNow);
+ while (reader.TryRead(out var job))
+ {
+ ProcessJob(job, DateTimeOffset.UtcNow);
+ }
}
}
+ finally
+ {
+ Interlocked.Exchange(ref readerActive, 0);
+ }
}
-
private void ProcessJob(UserJob job, DateTimeOffset nowUtc)
{
switch (job)
@@ -354,3 +377,4 @@ public void BroadcastAsync(byte[] payload)
+
From d65efed974b6c6c2fa1630e49db06714d9b9ee49 Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Sat, 22 Aug 2026 16:03:37 +0800
Subject: [PATCH 15/32] Add file watcher for hot-reloading users.json
---
.../UserFileWatcherTests.cs | 240 +++++++++++++++++
TextCascade.Server/Hosting/UserFileWatcher.cs | 253 ++++++++++++++++++
TextCascade.Server/ServerHost.cs | 4 +-
TextCascade.Server/SyncServer.cs | 10 +-
4 files changed, 504 insertions(+), 3 deletions(-)
create mode 100644 TextCascade.Server.Tests/UserFileWatcherTests.cs
create mode 100644 TextCascade.Server/Hosting/UserFileWatcher.cs
diff --git a/TextCascade.Server.Tests/UserFileWatcherTests.cs b/TextCascade.Server.Tests/UserFileWatcherTests.cs
new file mode 100644
index 0000000..52f4bb6
--- /dev/null
+++ b/TextCascade.Server.Tests/UserFileWatcherTests.cs
@@ -0,0 +1,240 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using TextCascade.Server;
+
+namespace TextCascade.Server.Tests;
+
+public class UserFileWatcherTests
+{
+ private const string ValidHash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aG9zdA";
+
+ private static async Task WaitUntilAsync(Func condition, TimeSpan timeout)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (DateTime.UtcNow < deadline)
+ {
+ if (condition()) return;
+ await Task.Delay(20);
+ }
+ Assert.True(condition(), "Condition was not met within timeout.");
+ }
+
+ [Fact]
+ public async Task ReloadReplacesUserLookupAfterSave()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ var tempUsers = Path.Combine(tempDir, "users.json");
+ var tempState = Path.Combine(tempDir, "state.json");
+ try
+ {
+ var initialUsers = new UsersFile
+ {
+ Users = [new UserRecord("alice", ValidHash, 1)],
+ NextTokenVersion = 2,
+ };
+ UsersFile.SaveUsers(tempUsers, initialUsers);
+
+ var config = TextCascade.Server.Config.CreateDefaultConfig() with
+ {
+ TokenSecret = new byte[32],
+ Files = new FilesConfig(tempUsers, tempState),
+ };
+ var server = new SyncServer(
+ config,
+ initialUsers,
+ new RuntimeStateStore(tempState),
+ new Argon2PasswordHasher(),
+ new SystemClock(),
+ NullLogger.Instance);
+
+ using var watcher = new UserFileWatcher(
+ tempUsers,
+ server,
+ NullLogger.Instance,
+ debounce: TimeSpan.FromMilliseconds(20),
+ pollFallback: TimeSpan.FromSeconds(1));
+ watcher.Start();
+
+ Assert.True(server.UserLookup.ContainsKey("alice"));
+ Assert.False(server.UserLookup.ContainsKey("bob"));
+
+ // Add bob and save
+ var updatedUsers = new UsersFile
+ {
+ Users =
+ [
+ new UserRecord("alice", ValidHash, 1),
+ new UserRecord("bob", ValidHash, 2),
+ ],
+ NextTokenVersion = 3,
+ };
+ UsersFile.SaveUsers(tempUsers, updatedUsers);
+
+ await WaitUntilAsync(() => server.UserLookup.ContainsKey("bob"), TimeSpan.FromSeconds(3));
+ Assert.True(server.UserLookup.ContainsKey("bob"));
+ Assert.True(server.UserLookup.ContainsKey("alice"));
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public async Task InvalidReloadRetainsPreviousLookup()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ var tempUsers = Path.Combine(tempDir, "users.json");
+ var tempState = Path.Combine(tempDir, "state.json");
+ try
+ {
+ var initialUsers = new UsersFile
+ {
+ Users = [new UserRecord("alice", ValidHash, 1)],
+ NextTokenVersion = 2,
+ };
+ UsersFile.SaveUsers(tempUsers, initialUsers);
+
+ var config = TextCascade.Server.Config.CreateDefaultConfig() with
+ {
+ TokenSecret = new byte[32],
+ Files = new FilesConfig(tempUsers, tempState),
+ };
+ var server = new SyncServer(
+ config,
+ initialUsers,
+ new RuntimeStateStore(tempState),
+ new Argon2PasswordHasher(),
+ new SystemClock(),
+ NullLogger.Instance);
+
+ using var watcher = new UserFileWatcher(
+ tempUsers,
+ server,
+ NullLogger.Instance,
+ debounce: TimeSpan.FromMilliseconds(20),
+ pollFallback: TimeSpan.FromSeconds(1));
+ watcher.Start();
+
+ // Write invalid JSON
+ await File.WriteAllTextAsync(tempUsers, "invalid json content!@#$");
+
+ // Wait a bit to ensure watcher event processed
+ await Task.Delay(200);
+
+ // Previous lookup must still be alice
+ Assert.True(server.UserLookup.ContainsKey("alice"));
+
+ // Now recover with valid file containing charlie
+ var recoveredUsers = new UsersFile
+ {
+ Users = [new UserRecord("charlie", ValidHash, 3)],
+ NextTokenVersion = 4,
+ };
+ UsersFile.SaveUsers(tempUsers, recoveredUsers);
+
+ await WaitUntilAsync(() => server.UserLookup.ContainsKey("charlie"), TimeSpan.FromSeconds(3));
+ Assert.True(server.UserLookup.ContainsKey("charlie"));
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public async Task ConcurrentReloadObserversAlwaysSeeCompleteDictionary()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ var tempUsers = Path.Combine(tempDir, "users.json");
+ var tempState = Path.Combine(tempDir, "state.json");
+ try
+ {
+ var usersA = new UsersFile
+ {
+ Users = [new UserRecord("alice", ValidHash, 1), new UserRecord("bob", ValidHash, 2)],
+ NextTokenVersion = 3,
+ };
+ var usersB = new UsersFile
+ {
+ Users = [new UserRecord("charlie", ValidHash, 3), new UserRecord("david", ValidHash, 4)],
+ NextTokenVersion = 5,
+ };
+
+ var config = TextCascade.Server.Config.CreateDefaultConfig() with { TokenSecret = new byte[32] };
+ var server = new SyncServer(
+ config,
+ usersA,
+ new RuntimeStateStore(tempState),
+ new Argon2PasswordHasher(),
+ new SystemClock(),
+ NullLogger.Instance);
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
+ var token = cts.Token;
+
+ // Reader task
+ var readerTask = Task.Run(() =>
+ {
+ while (!token.IsCancellationRequested)
+ {
+ var lookup = server.UserLookup;
+ // It must be either set A (alice & bob) or set B (charlie & david)
+ var isA = lookup.ContainsKey("alice") && lookup.ContainsKey("bob") && lookup.Count == 2;
+ var isB = lookup.ContainsKey("charlie") && lookup.ContainsKey("david") && lookup.Count == 2;
+ Assert.True(isA || isB, "Observed incomplete or mixed dictionary.");
+ }
+ });
+
+ // Writer loop replacing lookups
+ var writerTask = Task.Run(async () =>
+ {
+ var toggle = false;
+ while (!token.IsCancellationRequested)
+ {
+ server.ReplaceUserLookup(toggle ? usersA : usersB);
+ toggle = !toggle;
+ await Task.Yield();
+ }
+ });
+
+ await Task.WhenAll(readerTask, writerTask);
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void WatcherDisposeIsIdempotent()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ var tempUsers = Path.Combine(tempDir, "users.json");
+ var tempState = Path.Combine(tempDir, "state.json");
+ try
+ {
+ var initialUsers = new UsersFile();
+ var config = TextCascade.Server.Config.CreateDefaultConfig() with { TokenSecret = new byte[32] };
+ var server = new SyncServer(
+ config,
+ initialUsers,
+ new RuntimeStateStore(tempState),
+ new Argon2PasswordHasher(),
+ new SystemClock(),
+ NullLogger.Instance);
+
+ var watcher = new UserFileWatcher(tempUsers, server, NullLogger.Instance);
+ watcher.Start();
+ watcher.Dispose();
+ watcher.Dispose(); // Should not throw
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+ }
+}
diff --git a/TextCascade.Server/Hosting/UserFileWatcher.cs b/TextCascade.Server/Hosting/UserFileWatcher.cs
new file mode 100644
index 0000000..0715b74
--- /dev/null
+++ b/TextCascade.Server/Hosting/UserFileWatcher.cs
@@ -0,0 +1,253 @@
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+
+namespace TextCascade.Server;
+
+internal sealed class UserFileWatcher : IDisposable
+{
+ private readonly string usersPath;
+ private readonly SyncServer server;
+ private readonly ILogger logger;
+ private readonly TimeSpan debounce;
+ private readonly TimeSpan pollFallback;
+
+ private readonly object scheduleGate = new();
+ private CancellationTokenSource? reloadDelay;
+ private CancellationTokenSource? reloadExecution;
+ private int reloadQueued;
+
+ private FileSystemWatcher? watcher;
+ private PeriodicTimer? pollTimer;
+ private Task? pollTask;
+ private bool started;
+ private bool disposed;
+
+ public UserFileWatcher(
+ string usersPath,
+ SyncServer server,
+ ILogger logger,
+ TimeSpan? debounce = null,
+ TimeSpan? pollFallback = null)
+ {
+ this.usersPath = Path.GetFullPath(usersPath);
+ this.server = server;
+ this.logger = logger;
+ this.debounce = debounce ?? TimeSpan.FromMilliseconds(250);
+ this.pollFallback = pollFallback ?? TimeSpan.FromSeconds(30);
+ }
+
+ public void Start()
+ {
+ lock (scheduleGate)
+ {
+ if (started || disposed) return;
+ started = true;
+
+ var directory = Path.GetDirectoryName(usersPath);
+ if (string.IsNullOrEmpty(directory))
+ {
+ throw new InvalidOperationException("Users file path must include a parent directory.");
+ }
+
+ if (!Directory.Exists(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ var fileName = Path.GetFileName(usersPath);
+
+ watcher = new FileSystemWatcher(directory, fileName)
+ {
+ NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.CreationTime,
+ };
+
+ watcher.Changed += OnFileChanged;
+ watcher.Created += OnFileChanged;
+ watcher.Deleted += OnFileChanged;
+ watcher.Renamed += OnFileRenamed;
+ watcher.Error += OnFileError;
+ watcher.EnableRaisingEvents = true;
+
+ pollTimer = new PeriodicTimer(pollFallback);
+ reloadExecution = new CancellationTokenSource();
+ var executionToken = reloadExecution.Token;
+
+ pollTask = Task.Run(async () =>
+ {
+ try
+ {
+ while (await pollTimer.WaitForNextTickAsync(executionToken))
+ {
+ ScheduleReload();
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ });
+ }
+ }
+
+ private void OnFileChanged(object? sender, FileSystemEventArgs eventArgs)
+ {
+ var comparison = OperatingSystem.IsWindows()
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal;
+
+ var fullPath = Path.GetFullPath(eventArgs.FullPath);
+ if (string.Equals(fullPath, usersPath, comparison))
+ {
+ ScheduleReload();
+ }
+ }
+
+ private void OnFileRenamed(object? sender, RenamedEventArgs eventArgs)
+ {
+ var comparison = OperatingSystem.IsWindows()
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal;
+
+ var fullPath = Path.GetFullPath(eventArgs.FullPath);
+ var oldFullPath = Path.GetFullPath(eventArgs.OldFullPath);
+ if (string.Equals(fullPath, usersPath, comparison) || string.Equals(oldFullPath, usersPath, comparison))
+ {
+ ScheduleReload();
+ }
+ }
+
+ private void OnFileError(object? sender, ErrorEventArgs eventArgs)
+ {
+ logger.LogWarning(eventArgs.GetException(), "Users file watcher error encountered.");
+ }
+
+ private void ScheduleReload()
+ {
+ lock (scheduleGate)
+ {
+ if (disposed) return;
+
+ // If a delay is already running, do not reset it
+ if (reloadDelay is not null && !reloadDelay.IsCancellationRequested)
+ {
+ return;
+ }
+
+ reloadDelay = new CancellationTokenSource();
+ var delayToken = reloadDelay.Token;
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await Task.Delay(debounce, delayToken);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+
+ lock (scheduleGate)
+ {
+ if (disposed) return;
+ reloadDelay?.Dispose();
+ reloadDelay = null;
+ }
+
+ if (Interlocked.CompareExchange(ref reloadQueued, 1, 0) == 0)
+ {
+ try
+ {
+ await ReloadAsync();
+ }
+ finally
+ {
+ Interlocked.Exchange(ref reloadQueued, 0);
+ }
+ }
+ });
+ }
+ }
+
+ private async Task ReloadAsync()
+ {
+ UsersFile? users = null;
+ Exception? lastException = null;
+
+ // Try reading with short backoff (3 attempts, 50ms exponential backoff)
+ for (var attempt = 0; attempt < 3; attempt++)
+ {
+ try
+ {
+ users = await Task.Run(() => UsersFile.LoadUsers(usersPath));
+ lastException = null;
+ break;
+ }
+ catch (Exception exception) when (
+ exception is IOException
+ or JsonException
+ or DecoderFallbackException
+ or InvalidOperationException
+ or UnauthorizedAccessException)
+ {
+ lastException = exception;
+ await Task.Delay(TimeSpan.FromMilliseconds(50 * (attempt + 1)));
+ }
+ }
+
+ if (users is not null)
+ {
+ try
+ {
+ server.ReplaceUserLookup(users);
+ logger.LogInformation("Users file reloaded. users={Count}", users.Users.Count);
+ }
+ catch (Exception exception) when (exception is InvalidOperationException)
+ {
+ logger.LogWarning(exception, "Users file reload validation failed; retaining previous users. path={Path}", usersPath);
+ }
+ }
+ else if (lastException is not null)
+ {
+ logger.LogWarning(lastException, "Users file reload failed; retaining previous users. path={Path}", usersPath);
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (scheduleGate)
+ {
+ if (disposed) return;
+ disposed = true;
+
+ try
+ {
+ reloadDelay?.Cancel();
+ reloadDelay?.Dispose();
+ }
+ catch { }
+
+ try
+ {
+ reloadExecution?.Cancel();
+ reloadExecution?.Dispose();
+ }
+ catch { }
+
+ if (watcher is not null)
+ {
+ watcher.EnableRaisingEvents = false;
+ watcher.Changed -= OnFileChanged;
+ watcher.Created -= OnFileChanged;
+ watcher.Deleted -= OnFileChanged;
+ watcher.Renamed -= OnFileRenamed;
+ watcher.Error -= OnFileError;
+ watcher.Dispose();
+ watcher = null;
+ }
+
+ pollTimer?.Dispose();
+ pollTimer = null;
+ }
+ }
+}
diff --git a/TextCascade.Server/ServerHost.cs b/TextCascade.Server/ServerHost.cs
index a76c782..f41f541 100644
--- a/TextCascade.Server/ServerHost.cs
+++ b/TextCascade.Server/ServerHost.cs
@@ -1,4 +1,4 @@
-using System.Net;
+using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -88,6 +88,8 @@ public static int RunServer(string[] args)
app.MapGet("/api/v1/sync", async context => await SyncEndpoint.HandleAsync(context, config, context.RequestServices.GetRequiredService()));
app.MapMethods("/health", new[] { "HEAD" }, () => Results.Json(new { status = "ok" }));
+ using var userFileWatcher = new UserFileWatcher(config.Files.UsersFile, app.Services.GetRequiredService(), app.Logger);
+ userFileWatcher.Start();
app.Run();
}
return Ok;
diff --git a/TextCascade.Server/SyncServer.cs b/TextCascade.Server/SyncServer.cs
index 808feee..e5d1809 100644
--- a/TextCascade.Server/SyncServer.cs
+++ b/TextCascade.Server/SyncServer.cs
@@ -23,7 +23,7 @@ public sealed class SyncServer : IConnectionCoordinator
private readonly IPasswordHasher hasher;
private readonly IClock clock;
private readonly RuntimeStateStore runtimeStateStore;
- private readonly IReadOnlyDictionary userLookup;
+ private IReadOnlyDictionary userLookup;
private readonly string loginDummyHash;
public UserRegistry Registry => registry;
@@ -31,7 +31,7 @@ public sealed class SyncServer : IConnectionCoordinator
public SlidingWindowLoginLimiter LoginLimiter { get; } = new();
public IClock Clock => clock;
public ILogger Logger { get; }
- public IReadOnlyDictionary UserLookup => userLookup;
+ public IReadOnlyDictionary UserLookup => Volatile.Read(ref userLookup);
public DateTimeOffset ProcessStartTime { get; }
public RuntimeConfig Config { get; }
public RuntimeStateStore RuntimeStateStore => runtimeStateStore;
@@ -63,6 +63,12 @@ public SyncServer(
public void RemoveEmptyHubAfterRecovery(UserHub hub) => registry.RemoveIfEmpty(hub, allowDuringRecovery: true);
+ public void ReplaceUserLookup(UsersFile users)
+ {
+ var replacement = UsersFile.BuildUserLookup(users);
+ Volatile.Write(ref userLookup, replacement);
+ }
+
public UserHub GetOrCreateHub(string username, RuntimeConfig runtimeConfig)
{
var initialVersion = runtimeStateStore.GetVersion(username);
From d818739a0b3613b518e8b62eb086f981dda69d1d Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Sat, 22 Aug 2026 16:04:48 +0800
Subject: [PATCH 16/32] Eliminate frame.ToArray in WebSocket JSON message
parsing
---
TextCascade.Server/Hosting/ConnectionHandler.cs | 3 ++-
TextCascade.Server/Models/ReceivedMessage.cs | 4 ++--
TextCascade.Server/Protocol.cs | 7 ++++---
3 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/TextCascade.Server/Hosting/ConnectionHandler.cs b/TextCascade.Server/Hosting/ConnectionHandler.cs
index de94052..abdbd68 100644
--- a/TextCascade.Server/Hosting/ConnectionHandler.cs
+++ b/TextCascade.Server/Hosting/ConnectionHandler.cs
@@ -1,4 +1,4 @@
-using System.Globalization;
+using System.Globalization;
using System.Net.WebSockets;
using System.Text;
using Microsoft.Extensions.Logging;
@@ -267,3 +267,4 @@ private static async Task SendSafeAsync(ConnectionContext connection, byte[] pay
internal sealed class FrameTooLargeException : Exception;
+
diff --git a/TextCascade.Server/Models/ReceivedMessage.cs b/TextCascade.Server/Models/ReceivedMessage.cs
index 04e844f..f276026 100644
--- a/TextCascade.Server/Models/ReceivedMessage.cs
+++ b/TextCascade.Server/Models/ReceivedMessage.cs
@@ -1,5 +1,5 @@
-using System.Net.WebSockets;
+using System.Net.WebSockets;
namespace TextCascade.Server;
-internal sealed record ReceivedMessage(WebSocketMessageType MessageType, byte[] Payload);
+internal sealed record ReceivedMessage(WebSocketMessageType MessageType, ReadOnlyMemory Payload);
diff --git a/TextCascade.Server/Protocol.cs b/TextCascade.Server/Protocol.cs
index 83360b4..bf63bfa 100644
--- a/TextCascade.Server/Protocol.cs
+++ b/TextCascade.Server/Protocol.cs
@@ -244,7 +244,7 @@ public static byte[] SerializeLoginResponse(AuthToken token, RuntimeConfig confi
return stream.ToArray();
}
- public static ParseResult ParseClientMessage(ReadOnlySpan frame, RuntimeConfig config)
+ public static ParseResult ParseClientMessage(ReadOnlyMemory frame, RuntimeConfig config)
{
using var document = TryParseJson(frame, out var parseError);
if (parseError is not null)
@@ -458,11 +458,11 @@ private static bool ValidatePayloadSize(string payload, RuntimeConfig config, ou
return true;
}
- private static JsonDocument? TryParseJson(ReadOnlySpan frame, out ProtocolError? error)
+ private static JsonDocument? TryParseJson(ReadOnlyMemory frame, out ProtocolError? error)
{
try
{
- var document = JsonDocument.Parse(frame.ToArray(), new JsonDocumentOptions
+ var document = JsonDocument.Parse(frame, new JsonDocumentOptions
{
AllowTrailingCommas = false,
CommentHandling = JsonCommentHandling.Disallow,
@@ -597,3 +597,4 @@ private ParseResult(MessageKind kind, object? message, ProtocolError? error)
public static ParseResult Failure(ProtocolError error) => new(MessageKind.Unknown, null, error);
}
+
From 98fe7e89764470a374b5eb0c0566be58a173aae8 Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Sat, 22 Aug 2026 16:15:56 +0800
Subject: [PATCH 17/32] Add end-to-end WebSocket integration tests and
ServerHost.CreateApp
---
.../WebSocketIntegrationTests.cs | 488 ++++++++++++++++++
TextCascade.Server/ServerHost.cs | 37 +-
2 files changed, 516 insertions(+), 9 deletions(-)
create mode 100644 TextCascade.Server.Tests/WebSocketIntegrationTests.cs
diff --git a/TextCascade.Server.Tests/WebSocketIntegrationTests.cs b/TextCascade.Server.Tests/WebSocketIntegrationTests.cs
new file mode 100644
index 0000000..5602431
--- /dev/null
+++ b/TextCascade.Server.Tests/WebSocketIntegrationTests.cs
@@ -0,0 +1,488 @@
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using TextCascade.Server;
+
+namespace TextCascade.Server.Tests;
+
+public class WebSocketIntegrationTests
+{
+ private const string ValidHash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aG9zdA";
+
+ private sealed class TestLogCollector : ILoggerProvider, ILogger
+ {
+ public List Entries { get; } = new();
+
+ public ILogger CreateLogger(string categoryName) => this;
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
+ {
+ lock (Entries)
+ {
+ Entries.Add(formatter(state, exception));
+ }
+ }
+
+ public void Dispose() { }
+ }
+
+ private sealed class FastPasswordHasher : IPasswordHasher
+ {
+ public string Hash(string password, Isopoh.Cryptography.Argon2.Argon2Config config) => ValidHash;
+
+ public bool Verify(string password, string encodedHash)
+ {
+ return (password == "password123" && encodedHash == ValidHash);
+ }
+
+ public bool NeedsRehash(string encodedHash, Isopoh.Cryptography.Argon2.Argon2Config config) => false;
+ }
+
+ private sealed class IntegrationTestFixture : IAsyncDisposable
+ {
+ public WebApplication Application { get; }
+ public HttpClient Client { get; }
+ public string BaseUrl { get; }
+ public string WebSocketUrl { get; }
+ public string TempDir { get; }
+ public RuntimeConfig Config { get; }
+ public TestLogCollector Logs { get; } = new();
+
+ public static async Task CreateAsync(
+ Func? configModifier = null,
+ Action? usersOverride = null,
+ Action? stateOverride = null)
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ var usersPath = Path.Combine(tempDir, "users.json");
+ var statePath = Path.Combine(tempDir, "state.json");
+
+ var users = new UsersFile
+ {
+ Users =
+ [
+ new UserRecord("alice", ValidHash, 1),
+ new UserRecord("bob", ValidHash, 1),
+ ],
+ NextTokenVersion = 2,
+ };
+ usersOverride?.Invoke(users);
+ UsersFile.SaveUsers(usersPath, users);
+
+ var stateStore = new RuntimeStateStore(statePath);
+ stateOverride?.Invoke(stateStore);
+
+ var config = TextCascade.Server.Config.CreateDefaultConfig() with
+ {
+ TokenSecret = Encoding.UTF8.GetBytes("12345678901234567890123456789012"),
+ Files = new FilesConfig(usersPath, statePath),
+ Server = new ServerConfig("127.0.0.1", 0, "dummy.pem"),
+ Limits = TextCascade.Server.Config.CreateDefaultConfig().Limits with { SnapshotWindowSeconds = 0 },
+ };
+ if (configModifier is not null)
+ {
+ config = configModifier(config);
+ }
+
+ var app = ServerHost.CreateApp(
+ [],
+ config,
+ users,
+ stateStore,
+ hasher: new FastPasswordHasher(),
+ clock: new SystemClock(),
+ certificate: null);
+
+ var logs = new TestLogCollector();
+ app.Services.GetRequiredService().AddProvider(logs);
+
+ app.Urls.Add("http://127.0.0.1:0");
+ await app.StartAsync();
+
+ var boundUrl = app.Urls.First();
+ var uri = new Uri(boundUrl);
+ var wsUrl = $"ws://{uri.Authority}/api/v1/sync";
+
+ var client = new HttpClient { BaseAddress = uri };
+
+ return new IntegrationTestFixture(app, client, boundUrl, wsUrl, tempDir, config, logs);
+ }
+
+ private IntegrationTestFixture(
+ WebApplication app,
+ HttpClient client,
+ string baseUrl,
+ string wsUrl,
+ string tempDir,
+ RuntimeConfig config,
+ TestLogCollector logs)
+ {
+ Application = app;
+ Client = client;
+ BaseUrl = baseUrl;
+ WebSocketUrl = wsUrl;
+ TempDir = tempDir;
+ Config = config;
+ Logs = logs;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ Client.Dispose();
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
+ try
+ {
+ await Application.StopAsync(cts.Token);
+ }
+ catch { }
+
+ await Application.DisposeAsync();
+
+ if (Directory.Exists(TempDir))
+ {
+ try { Directory.Delete(TempDir, true); } catch { }
+ }
+ }
+ }
+
+ private static async Task LoginAsync(HttpClient client, string username, string password)
+ {
+ var response = await client.PostAsJsonAsync("/api/v1/login", new { username, password });
+ Assert.True(response.IsSuccessStatusCode, $"Login failed: {response.StatusCode}");
+
+ using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
+ Assert.Equal(1, doc.RootElement.GetProperty("protocolVersion").GetInt32());
+ var token = doc.RootElement.GetProperty("token").GetString();
+ Assert.False(string.IsNullOrEmpty(token));
+ return token!;
+ }
+
+ private static async Task ConnectWebSocketAsync(string wsUrl, string token)
+ {
+ var ws = new ClientWebSocket();
+ ws.Options.AddSubProtocol("textcascade.v1");
+ ws.Options.SetRequestHeader("Authorization", $"Bearer {token}");
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ await ws.ConnectAsync(new Uri(wsUrl), cts.Token);
+ Assert.Equal(WebSocketState.Open, ws.State);
+ Assert.Equal("textcascade.v1", ws.SubProtocol);
+ return ws;
+ }
+
+ private static async Task SendJsonAsync(ClientWebSocket ws, object msg)
+ {
+ var bytes = JsonSerializer.SerializeToUtf8Bytes(msg);
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ await ws.SendAsync(bytes, WebSocketMessageType.Text, true, cts.Token);
+ }
+
+ private static async Task ReceiveJsonAsync(ClientWebSocket ws)
+ {
+ var buffer = new byte[64 * 1024];
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ var result = await ws.ReceiveAsync(new ArraySegment(buffer), cts.Token);
+ Assert.Equal(WebSocketMessageType.Text, result.MessageType);
+ return JsonDocument.Parse(buffer.AsMemory(0, result.Count));
+ }
+
+ private static async Task CloseWsAsync(ClientWebSocket? ws)
+ {
+ if (ws is not null && ws.State == WebSocketState.Open)
+ {
+ try
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
+ await ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "done", cts.Token);
+ }
+ catch { }
+ }
+ ws?.Dispose();
+ }
+
+ [Fact]
+ public async Task LoginAndWebSocketHandshakeRoundTrips()
+ {
+ await using var fixture = await IntegrationTestFixture.CreateAsync();
+
+ var token = await LoginAsync(fixture.Client, "alice", "password123");
+ var ws = await ConnectWebSocketAsync(fixture.WebSocketUrl, token);
+ try
+ {
+ // Send hello
+ await SendJsonAsync(ws, new
+ {
+ type = "hello",
+ clientId = "client-1",
+ clientName = "Device 1",
+ lastServerVersion = 0,
+ snapshot = (object?)null,
+ });
+
+ // Receive welcome
+ using var welcomeDoc = await ReceiveJsonAsync(ws);
+ var root = welcomeDoc.RootElement;
+ Assert.Equal("welcome", root.GetProperty("type").GetString());
+ Assert.Equal(1, root.GetProperty("protocolVersion").GetInt32());
+ Assert.False(root.TryGetProperty("latest", out var latest) && latest.ValueKind == JsonValueKind.Object);
+ }
+ finally
+ {
+ await CloseWsAsync(ws);
+ }
+ }
+
+ [Fact]
+ public async Task ClipBroadcastsToSecondClient()
+ {
+ await using var fixture = await IntegrationTestFixture.CreateAsync();
+
+ var tokenA = await LoginAsync(fixture.Client, "alice", "password123");
+ var tokenB = await LoginAsync(fixture.Client, "alice", "password123");
+
+ var wsA = await ConnectWebSocketAsync(fixture.WebSocketUrl, tokenA);
+ var wsB = await ConnectWebSocketAsync(fixture.WebSocketUrl, tokenB);
+ try
+ {
+ // Hello from A
+ await SendJsonAsync(wsA, new
+ {
+ type = "hello",
+ clientId = "client-A",
+ clientName = "Device A",
+ lastServerVersion = 0,
+ snapshot = (object?)null,
+ });
+ using var welcomeA = await ReceiveJsonAsync(wsA);
+
+ // Hello from B
+ await SendJsonAsync(wsB, new
+ {
+ type = "hello",
+ clientId = "client-B",
+ clientName = "Device B",
+ lastServerVersion = 0,
+ snapshot = (object?)null,
+ });
+ using var welcomeB = await ReceiveJsonAsync(wsB);
+
+ // A sends clip
+ await SendJsonAsync(wsA, new
+ {
+ type = "clip",
+ id = "clip-msg-1",
+ payload = "Hello World Broadcast",
+ encrypted = false,
+ hash = "h1",
+ });
+
+ // A receives clip_ack
+ using var ackA = await ReceiveJsonAsync(wsA);
+ Assert.Equal("clip_ack", ackA.RootElement.GetProperty("type").GetString());
+ Assert.Equal("clip-msg-1", ackA.RootElement.GetProperty("id").GetString());
+ Assert.Equal(1UL, ackA.RootElement.GetProperty("version").GetUInt64());
+
+ // B receives broadcast clip
+ using var clipB = await ReceiveJsonAsync(wsB);
+ Assert.Equal("clip", clipB.RootElement.GetProperty("type").GetString());
+ Assert.Equal("clip-msg-1", clipB.RootElement.GetProperty("id").GetString());
+ Assert.Equal("Hello World Broadcast", clipB.RootElement.GetProperty("payload").GetString());
+ Assert.Equal(1UL, clipB.RootElement.GetProperty("version").GetUInt64());
+
+ // B sends same clip (id & payload duplicate)
+ await SendJsonAsync(wsB, new
+ {
+ type = "clip",
+ id = "clip-msg-1",
+ payload = "Hello World Broadcast",
+ encrypted = false,
+ hash = "h1",
+ });
+
+ // B receives duplicate ack with same version
+ using var ackB = await ReceiveJsonAsync(wsB);
+ Assert.Equal("clip_ack", ackB.RootElement.GetProperty("type").GetString());
+ Assert.Equal("clip-msg-1", ackB.RootElement.GetProperty("id").GetString());
+ Assert.Equal(1UL, ackB.RootElement.GetProperty("version").GetUInt64());
+ }
+ finally
+ {
+ await CloseWsAsync(wsA);
+ await CloseWsAsync(wsB);
+ }
+ }
+
+ [Fact]
+ public async Task InvalidTokenDoesNotUpgradeWebSocket()
+ {
+ await using var fixture = await IntegrationTestFixture.CreateAsync();
+
+ var ws = new ClientWebSocket();
+ ws.Options.AddSubProtocol("textcascade.v1");
+ ws.Options.SetRequestHeader("Authorization", "Bearer invalid-signature-token-here");
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
+ await Assert.ThrowsAnyAsync(() => ws.ConnectAsync(new Uri(fixture.WebSocketUrl), cts.Token));
+ }
+
+ [Fact]
+ public async Task ReconnectRestoresHighestSnapshot()
+ {
+ await using var fixture = await IntegrationTestFixture.CreateAsync(
+ configModifier: cfg => cfg with { Limits = cfg.Limits with { SnapshotWindowSeconds = 10 } },
+ stateOverride: store =>
+ {
+ store.SaveVersion("alice", 7UL);
+ });
+
+ var token = await LoginAsync(fixture.Client, "alice", "password123");
+
+ var wsA = await ConnectWebSocketAsync(fixture.WebSocketUrl, token);
+ var wsB = await ConnectWebSocketAsync(fixture.WebSocketUrl, token);
+ try
+ {
+ var modifiedTime = DateTimeOffset.UtcNow;
+
+ // A sends hello with version 7
+ await SendJsonAsync(wsA, new
+ {
+ type = "hello",
+ clientId = "client-A",
+ clientName = "Device A",
+ lastServerVersion = 7,
+ snapshot = new
+ {
+ payload = "snapshot-v7",
+ encrypted = false,
+ hash = "hash7",
+ localModifiedAtUtc = modifiedTime.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"),
+ },
+ });
+
+ // B sends hello with version 8
+ await SendJsonAsync(wsB, new
+ {
+ type = "hello",
+ clientId = "client-B",
+ clientName = "Device B",
+ lastServerVersion = 8,
+ snapshot = new
+ {
+ payload = "snapshot-v8",
+ encrypted = false,
+ hash = "hash8",
+ localModifiedAtUtc = modifiedTime.AddSeconds(1).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"),
+ },
+ });
+
+ // Wait a moment for jobs to be processed in user channel
+ await Task.Delay(50);
+
+ // Explicitly close recovery window to trigger immediate election and broadcast
+ var syncServer = fixture.Application.Services.GetRequiredService();
+ var hub = syncServer.GetOrCreateHub("alice", fixture.Config);
+ hub.CloseRecoveryWindow(DateTimeOffset.UtcNow.AddMinutes(1));
+
+ // Both receives welcome with winning version 8
+ using var welcomeA = await ReceiveJsonAsync(wsA);
+ using var welcomeB = await ReceiveJsonAsync(wsB);
+
+ Assert.Equal("welcome", welcomeA.RootElement.GetProperty("type").GetString());
+ var latestA = welcomeA.RootElement.GetProperty("latest");
+ Assert.Equal(8UL, latestA.GetProperty("version").GetUInt64());
+ Assert.Equal("snapshot-v8", latestA.GetProperty("payload").GetString());
+
+ Assert.Equal("welcome", welcomeB.RootElement.GetProperty("type").GetString());
+ var latestB = welcomeB.RootElement.GetProperty("latest");
+ Assert.Equal(8UL, latestB.GetProperty("version").GetUInt64());
+ Assert.Equal("snapshot-v8", latestB.GetProperty("payload").GetString());
+ }
+ finally
+ {
+ await CloseWsAsync(wsA);
+ await CloseWsAsync(wsB);
+ }
+ }
+
+ [Fact]
+ public async Task AbruptDisconnectIsLoggedAndServerContinues()
+ {
+ await using var fixture = await IntegrationTestFixture.CreateAsync();
+
+ var token = await LoginAsync(fixture.Client, "alice", "password123");
+
+ var wsA = await ConnectWebSocketAsync(fixture.WebSocketUrl, token);
+ var wsB = await ConnectWebSocketAsync(fixture.WebSocketUrl, token);
+ try
+ {
+ // Hello from A
+ await SendJsonAsync(wsA, new
+ {
+ type = "hello",
+ clientId = "client-A",
+ clientName = "Device A",
+ lastServerVersion = 0,
+ snapshot = (object?)null,
+ });
+ using var welcomeA = await ReceiveJsonAsync(wsA);
+
+ // Hello from B
+ await SendJsonAsync(wsB, new
+ {
+ type = "hello",
+ clientId = "client-B",
+ clientName = "Device B",
+ lastServerVersion = 0,
+ snapshot = (object?)null,
+ });
+ using var welcomeB = await ReceiveJsonAsync(wsB);
+
+ // Abruptly abort client A socket
+ wsA.Abort();
+ wsA.Dispose();
+
+ // B sends clip - server continues properly
+ await SendJsonAsync(wsB, new
+ {
+ type = "clip",
+ id = "clip-after-abort",
+ payload = "Still works",
+ encrypted = false,
+ hash = "h2",
+ });
+
+ using var ackB = await ReceiveJsonAsync(wsB);
+ Assert.Equal("clip_ack", ackB.RootElement.GetProperty("type").GetString());
+ Assert.Equal("clip-after-abort", ackB.RootElement.GetProperty("id").GetString());
+
+ // Check structured logs for connect/disconnect
+ lock (fixture.Logs.Entries)
+ {
+ Assert.NotEmpty(fixture.Logs.Entries);
+ foreach (var log in fixture.Logs.Entries)
+ {
+ Assert.DoesNotContain("password123", log, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("12345678901234567890123456789012", log, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+ }
+ finally
+ {
+ await CloseWsAsync(wsB);
+ }
+ }
+}
+
+
diff --git a/TextCascade.Server/ServerHost.cs b/TextCascade.Server/ServerHost.cs
index f41f541..104cd54 100644
--- a/TextCascade.Server/ServerHost.cs
+++ b/TextCascade.Server/ServerHost.cs
@@ -57,16 +57,38 @@ public static int RunServer(string[] args)
using (certificate)
{
+ var app = CreateApp(args, config, users, stateStore, hasher: null, clock: null, certificate: certificate);
+ using var userFileWatcher = new UserFileWatcher(config.Files.UsersFile, app.Services.GetRequiredService(), app.Logger);
+ userFileWatcher.Start();
+ app.Run();
+ }
+ return Ok;
+ }
+
+ public static WebApplication CreateApp(
+ string[] args,
+ RuntimeConfig config,
+ UsersFile users,
+ RuntimeStateStore stateStore,
+ IPasswordHasher? hasher = null,
+ IClock? clock = null,
+ LoadedCertificate? certificate = null)
+ {
var builder = WebApplication.CreateBuilder(args);
- builder.WebHost.UseKestrel(ConfigureKestrel(config, certificate));
+ if (certificate is not null)
+ {
+ builder.WebHost.UseKestrel(ConfigureKestrel(config, certificate));
+ }
+
builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(options =>
{
options.SingleLine = true;
options.TimestampFormat = "yyyy-MM-ddTHH:mm:ssZ ";
});
- builder.Services.AddSingleton();
- builder.Services.AddSingleton();
+
+ builder.Services.AddSingleton(hasher ?? new Argon2PasswordHasher());
+ builder.Services.AddSingleton(clock ?? new SystemClock());
builder.Services.AddSingleton(stateStore);
builder.Services.AddSingleton(serviceProvider => new SyncServer(
config,
@@ -88,11 +110,7 @@ public static int RunServer(string[] args)
app.MapGet("/api/v1/sync", async context => await SyncEndpoint.HandleAsync(context, config, context.RequestServices.GetRequiredService()));
app.MapMethods("/health", new[] { "HEAD" }, () => Results.Json(new { status = "ok" }));
- using var userFileWatcher = new UserFileWatcher(config.Files.UsersFile, app.Services.GetRequiredService(), app.Logger);
- userFileWatcher.Start();
- app.Run();
- }
- return Ok;
+ return app;
}
private static Action ConfigureKestrel(RuntimeConfig config, LoadedCertificate certificate)
@@ -203,7 +221,7 @@ private static void DisposeChain(X509Certificate2Collection chain)
}
}
-internal sealed class LoadedCertificate : IDisposable
+public sealed class LoadedCertificate : IDisposable
{
public LoadedCertificate(X509Certificate2 certificate, X509Certificate2Collection chain)
{
@@ -224,3 +242,4 @@ public void Dispose()
}
}
+
From 9a1c668880dd1e5e3fd6e463dac922e56a024e4e Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Sat, 22 Aug 2026 16:18:04 +0800
Subject: [PATCH 18/32] Add Keep a Changelog CHANGELOG and update documentation
---
CHANGELOG.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++
README.md | 31 ++++++++++++++++++++------
2 files changed, 86 insertions(+), 7 deletions(-)
create mode 100644 CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..adecfd0
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,62 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Added
+- Native ASP.NET Core `CreateApp` factory and comprehensive end-to-end WebSocket integration tests covering handshake, broadcast, snapshots, invalid tokens, and abrupt disconnections.
+- Hot-reloading of `users.json` via file system watcher with debounced reload and periodic fallback.
+- Constant-time password verification on login for non-existent users using a cached dummy hash.
+
+### Changed
+- Refactored `SyncServer` by splitting models, hubs, and hosting services into dedicated domain files (`Models/`, `Hub/`, `Hosting/`).
+- Decoupled `UserHub` from `SyncServer` using lightweight `IConnectionCoordinator` interface.
+- Protected `UserHub.StartIfIdle` with mutex lock and added single-reader re-entrancy protection to `RunUserLoopAsync`.
+- Optimized `SeenIdRing` deduplication with hash map lookup (`Dictionary`) and FIFO circular eviction queue.
+- Migrated PEM certificate and private key loading to native .NET APIs (`X509Certificate2.CreateFromPemFile` and `X509Certificate2Collection.ImportFromPemFile`).
+- Relocated CLI single-instance lock file adjacent to the target `users.json` (`users.json.lock`).
+- Switched WebSocket JSON message parsing to `ReadOnlyMemory` to eliminate redundant byte array copies (`frame.ToArray()`).
+
+### Fixed
+- Fixed sliding window login rate limiter to clean up only expired timestamps from queue head rather than evicting the entire key.
+
+### Security
+- Eliminated username existence timing side-channel during login authentication.
+
+## [0.2.5] - 2026-08-22
+
+### Added
+- Version persistence across server restarts using `RuntimeStateStore` (`textcascade.state.json`).
+- Reconnection snapshot recovery window during server startup.
+
+### Changed
+- Refactored snapshot selection tie-breaking and broadcast logic.
+
+### Fixed
+- Fixed server protocol bugs in version negotiation and error responses.
+
+## [0.2.1] - 2026-08-18
+
+### Fixed
+- Fixed server protocol bugs in message deserialization and error framing.
+
+## [0.2.0] - 2026-08-18
+
+### Added
+- Cross-platform release workflows and CI matrix for Linux and Windows single-file binaries.
+- Bilingual README and documentation.
+
+## [0.1.0] - 2026-08-18
+
+### Added
+- Initial import and baseline release of TextCascade Server with Minimal API, Kestrel WebSocket, Argon2 password hashing, and token authentication.
+
+[Unreleased]: https://github.com/long45343/TextCascade-Server/compare/v0.2.5...HEAD
+[0.2.5]: https://github.com/long45343/TextCascade-Server/compare/v0.2.1...v0.2.5
+[0.2.1]: https://github.com/long45343/TextCascade-Server/compare/v0.2.0...v0.2.1
+[0.2.0]: https://github.com/long45343/TextCascade-Server/compare/v0.1.0...v0.2.0
+[0.1.0]: https://github.com/long45343/TextCascade-Server/releases/tag/v0.1.0
diff --git a/README.md b/README.md
index d90d8f4..364d6b0 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@
| 密码哈希 | Argon2(id)(`Isopoh.Cryptography.Argon2`) |
| 用户存储 | `users.json` |
| 协议子协议 | `textcascade.v1` |
-| 产品版本 | SemVer,当前 `0.2.0` |
+| 产品版本 | SemVer,当前 `0.2.5` |
### 仓库结构
@@ -39,15 +39,19 @@ TextCascade-Server/
├── TextCascade.Server/ 服务端源码
│ ├── Program.cs 入口:serve / user CLI 分发
│ ├── ServerHost.cs 配置加载、证书、WebHost 构建、路由映射
-│ ├── SyncServer.cs 核心:UserHub/UserRegistry/连接与广播
+│ ├── SyncServer.cs 核心协调器
+│ ├── Hosting/ 端点、连接处理、心跳与文件监听
+│ ├── Hub/ UserHub、UserRegistry、协调器接口与任务
+│ ├── Models/ 连接上下文、状态模型与接收消息
│ ├── Protocol.cs JSON 协议模型与解析
│ ├── Auth.cs / AuthService.cs token 签发、校验、登录限流
│ ├── Users.cs / Cli.cs 用户文件与 CLI(add/passwd/...)
│ ├── RuntimeConfig.cs TOML 配置与默认值、环境变量覆盖
-│ └── Core.cs 限流等基础工具
+│ └── Core.cs 限流、去重环形队列等基础工具
├── TextCascade.Server.Tests/ xUnit 测试
├── deploy/ systemd unit、示例 TOML 与空 users.json
-└── TextCascade.Server.slnx 解决案
+├── CHANGELOG.md 版本变更记录
+└── TextCascade.Server.slnx 解决方案
```
### 快速开始
@@ -155,7 +159,7 @@ GitHub Release 提供两种 Framework-dependent 单文件包,目标机需预装
包内附带主程序、配置模板;Linux 包另附 systemd unit。每次 Release 同时提供 SHA-256 校验文件。
-推送 `v*.*.*` 标签(如 `v0.2.0`)会自动执行测试、构建双平台单文件包、生成校验和并发布 GitHub Release。`main` 分支和 Pull Request 会自动执行 restore/build/test CI。
+推送 `v*.*.*` 标签(如 `v0.2.5`)会自动执行测试、构建双平台单文件包、生成校验和并发布 GitHub Release。`main` 分支和 Pull Request 会自动执行 restore/build/test CI。
### 生产部署(systemd)
参考 `deploy/textcascade-server.service`:
@@ -170,6 +174,13 @@ GitHub Release 提供两种 Framework-dependent 单文件包,目标机需预装
---
+### 发版与维护规范
+
+- 每个用户可见版本发布前,需将 CHANGELOG.md 中的 [Unreleased] 部分归档为对应版本号与发版日期,并维护底部的 compare 链接。
+- 版本号遵循 [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html),版本变更记录遵循 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)。
+
+---
+
## English
A lightweight, reliable, high-performance server that synchronizes only the latest text value per user. No history, no database.
@@ -196,7 +207,7 @@ Built on ASP.NET Core Minimal API with native Kestrel WebSockets, TLS-terminated
| Password hash | Argon2(id) (`Isopoh.Cryptography.Argon2`) |
| User store | `users.json` |
| Subprotocol | `textcascade.v1` |
-| Version | SemVer, currently `0.2.0` |
+| Version | SemVer, currently `0.2.5` |
### Quick Start
@@ -305,7 +316,7 @@ GitHub Releases provides two framework-dependent single-file archives. The .NET
Each archive contains the executable and config template; the Linux archive also includes the systemd unit. Every Release includes a SHA-256 checksum file.
-Pushing a `v*.*.*` tag (for example `v0.2.0`) runs tests, builds both single-file archives, generates checksums, and publishes a GitHub Release. Pushes to `main` and pull requests run restore/build/test CI automatically.
+Pushing a `v*.*.*` tag (for example `v0.2.5`) runs tests, builds both single-file archives, generates checksums, and publishes a GitHub Release. Pushes to `main` and pull requests run restore/build/test CI automatically.
### Production (systemd)
See `deploy/textcascade-server.service`:
@@ -317,3 +328,9 @@ See `deploy/textcascade-server.service`:
### License
See the repository LICENSE if present.
+
+### Release & Maintenance Guidelines
+
+- Before releasing any user-visible version, update CHANGELOG.md by moving the [Unreleased] section to the target version number and release date, along with the compare link at the bottom.
+- Versioning adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) and change records follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+
From f9b7875f1c5c3ac688df4241b68a2db72a0c05ea Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Sat, 22 Aug 2026 22:31:36 +0800
Subject: [PATCH 19/32] Release v0.3.0
---
CHANGELOG.md | 5 ++++-
README.md | 8 ++++----
TextCascade.Server/TextCascade.Server.csproj | 3 ++-
3 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index adecfd0..d6a3793 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.3.0] - 2026-08-22
+
### Added
- Native ASP.NET Core `CreateApp` factory and comprehensive end-to-end WebSocket integration tests covering handshake, broadcast, snapshots, invalid tokens, and abrupt disconnections.
- Hot-reloading of `users.json` via file system watcher with debounced reload and periodic fallback.
@@ -55,7 +57,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Initial import and baseline release of TextCascade Server with Minimal API, Kestrel WebSocket, Argon2 password hashing, and token authentication.
-[Unreleased]: https://github.com/long45343/TextCascade-Server/compare/v0.2.5...HEAD
+[Unreleased]: https://github.com/long45343/TextCascade-Server/compare/v0.3.0...HEAD
+[0.3.0]: https://github.com/long45343/TextCascade-Server/compare/v0.2.5...v0.3.0
[0.2.5]: https://github.com/long45343/TextCascade-Server/compare/v0.2.1...v0.2.5
[0.2.1]: https://github.com/long45343/TextCascade-Server/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/long45343/TextCascade-Server/compare/v0.1.0...v0.2.0
diff --git a/README.md b/README.md
index 364d6b0..63be770 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@
| 密码哈希 | Argon2(id)(`Isopoh.Cryptography.Argon2`) |
| 用户存储 | `users.json` |
| 协议子协议 | `textcascade.v1` |
-| 产品版本 | SemVer,当前 `0.2.5` |
+| 产品版本 | SemVer,当前 `0.3.0` |
### 仓库结构
@@ -159,7 +159,7 @@ GitHub Release 提供两种 Framework-dependent 单文件包,目标机需预装
包内附带主程序、配置模板;Linux 包另附 systemd unit。每次 Release 同时提供 SHA-256 校验文件。
-推送 `v*.*.*` 标签(如 `v0.2.5`)会自动执行测试、构建双平台单文件包、生成校验和并发布 GitHub Release。`main` 分支和 Pull Request 会自动执行 restore/build/test CI。
+推送 `v*.*.*` 标签(如 `v0.3.0`)会自动执行测试、构建双平台单文件包、生成校验和并发布 GitHub Release。`main` 分支和 Pull Request 会自动执行 restore/build/test CI。
### 生产部署(systemd)
参考 `deploy/textcascade-server.service`:
@@ -207,7 +207,7 @@ Built on ASP.NET Core Minimal API with native Kestrel WebSockets, TLS-terminated
| Password hash | Argon2(id) (`Isopoh.Cryptography.Argon2`) |
| User store | `users.json` |
| Subprotocol | `textcascade.v1` |
-| Version | SemVer, currently `0.2.5` |
+| Version | SemVer, currently `0.3.0` |
### Quick Start
@@ -316,7 +316,7 @@ GitHub Releases provides two framework-dependent single-file archives. The .NET
Each archive contains the executable and config template; the Linux archive also includes the systemd unit. Every Release includes a SHA-256 checksum file.
-Pushing a `v*.*.*` tag (for example `v0.2.5`) runs tests, builds both single-file archives, generates checksums, and publishes a GitHub Release. Pushes to `main` and pull requests run restore/build/test CI automatically.
+Pushing a `v*.*.*` tag (for example `v0.3.0`) runs tests, builds both single-file archives, generates checksums, and publishes a GitHub Release. Pushes to `main` and pull requests run restore/build/test CI automatically.
### Production (systemd)
See `deploy/textcascade-server.service`:
diff --git a/TextCascade.Server/TextCascade.Server.csproj b/TextCascade.Server/TextCascade.Server.csproj
index 61d9f16..c4e31a7 100644
--- a/TextCascade.Server/TextCascade.Server.csproj
+++ b/TextCascade.Server/TextCascade.Server.csproj
@@ -4,7 +4,7 @@
net10.0
enable
enable
- 0.2.5
+ 0.3.0
TextCascade.Server
true
@@ -27,3 +27,4 @@
+
From 9ed6eba05749d600c5bf57f8d695869a81a7eabb Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Sat, 22 Aug 2026 23:56:17 +0800
Subject: [PATCH 20/32] Refactor RuntimeStateStore to lock-free CAS and
periodic background flush
---
CHANGELOG.md | 12 ++-
README.md | 6 +-
.../RuntimeStateAndProtocolTests.cs | 101 ++++++++++++++---
TextCascade.Server/RuntimeStateStore.cs | 102 +++++++++++++++---
TextCascade.Server/SyncServer.cs | 2 +
TextCascade.Server/TextCascade.Server.csproj | 4 +-
6 files changed, 190 insertions(+), 37 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d6a3793..fd27d71 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,4 @@
-# Changelog
+# Changelog
All notable changes to this project will be documented in this file.
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.3.5] - 2026-08-22
+
+### Changed
+- Refactored `RuntimeStateStore` to lock-free memory CAS updates using `ConcurrentDictionary` and background periodic flush (`PeriodicTimer`) with atomic snapshot persistence, eliminating synchronous disk I/O bottlenecks in the clip synchronization pipeline.
+- Added graceful shutdown flush hook to `SyncServer.ShutdownAsync` ensuring all pending version increments are flushed upon service stop.
+- Added concurrency, background periodic flush, and fault-tolerance unit tests for `RuntimeStateStore`.
+
## [0.3.0] - 2026-08-22
### Added
@@ -57,7 +64,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Initial import and baseline release of TextCascade Server with Minimal API, Kestrel WebSocket, Argon2 password hashing, and token authentication.
-[Unreleased]: https://github.com/long45343/TextCascade-Server/compare/v0.3.0...HEAD
+[Unreleased]: https://github.com/long45343/TextCascade-Server/compare/v0.3.5...HEAD
+[0.3.5]: https://github.com/long45343/TextCascade-Server/compare/v0.3.0...v0.3.5
[0.3.0]: https://github.com/long45343/TextCascade-Server/compare/v0.2.5...v0.3.0
[0.2.5]: https://github.com/long45343/TextCascade-Server/compare/v0.2.1...v0.2.5
[0.2.1]: https://github.com/long45343/TextCascade-Server/compare/v0.2.0...v0.2.1
diff --git a/README.md b/README.md
index 63be770..02599e8 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# TextCascade Server
+# TextCascade Server
[简体中文](#简体中文) | [English](#english)
@@ -30,7 +30,7 @@
| 密码哈希 | Argon2(id)(`Isopoh.Cryptography.Argon2`) |
| 用户存储 | `users.json` |
| 协议子协议 | `textcascade.v1` |
-| 产品版本 | SemVer,当前 `0.3.0` |
+| 产品版本 | SemVer,当前 `0.3.5` |
### 仓库结构
@@ -207,7 +207,7 @@ Built on ASP.NET Core Minimal API with native Kestrel WebSockets, TLS-terminated
| Password hash | Argon2(id) (`Isopoh.Cryptography.Argon2`) |
| User store | `users.json` |
| Subprotocol | `textcascade.v1` |
-| Version | SemVer, currently `0.3.0` |
+| Version | SemVer, currently `0.3.5` |
### Quick Start
diff --git a/TextCascade.Server.Tests/RuntimeStateAndProtocolTests.cs b/TextCascade.Server.Tests/RuntimeStateAndProtocolTests.cs
index 2795346..36a98dc 100644
--- a/TextCascade.Server.Tests/RuntimeStateAndProtocolTests.cs
+++ b/TextCascade.Server.Tests/RuntimeStateAndProtocolTests.cs
@@ -1,3 +1,4 @@
+using System.Collections.Concurrent;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using TextCascade.Server;
@@ -14,14 +15,76 @@ public void StateStorePersistsHighestVersionAtomically()
var path = Path.Combine(Path.GetTempPath(), $"textcascade-state-{Guid.NewGuid():N}.json");
try
{
- var first = new RuntimeStateStore(path);
- first.SaveVersion("alice", 7UL);
+ using (var first = new RuntimeStateStore(path, TimeSpan.Zero))
+ {
+ first.SaveVersion("alice", 7UL);
+ first.Flush();
+ }
+
+ using (var second = new RuntimeStateStore(path, TimeSpan.Zero))
+ {
+ second.SaveVersion("alice", 5UL);
+ second.Flush();
+
+ Assert.Equal(7UL, second.GetVersion("alice"));
+ }
+
+ using (var third = new RuntimeStateStore(path, TimeSpan.Zero))
+ {
+ Assert.Equal(7UL, third.GetVersion("alice"));
+ }
+ }
+ finally
+ {
+ if (File.Exists(path)) File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public async Task StateStoreFlushesPeriodicallyInBackground()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"textcascade-state-{Guid.NewGuid():N}.json");
+ try
+ {
+ using (var store = new RuntimeStateStore(path, TimeSpan.FromMilliseconds(50)))
+ {
+ store.SaveVersion("alice", 12UL);
+ Assert.Equal(12UL, store.GetVersion("alice"));
- var second = new RuntimeStateStore(path);
- second.SaveVersion("alice", 5UL);
+ // Wait for background timer tick
+ await Task.Delay(150);
- Assert.Equal(7UL, second.GetVersion("alice"));
- Assert.Equal(7UL, new RuntimeStateStore(path).GetVersion("alice"));
+ using var reloaded = new RuntimeStateStore(path, TimeSpan.Zero);
+ Assert.Equal(12UL, reloaded.GetVersion("alice"));
+ }
+ }
+ finally
+ {
+ if (File.Exists(path)) File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void StateStoreConcurrentSaveVersionMaintainsHighestValue()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"textcascade-state-{Guid.NewGuid():N}.json");
+ try
+ {
+ using var store = new RuntimeStateStore(path, TimeSpan.Zero);
+ Parallel.For(1, 100, i =>
+ {
+ store.SaveVersion("alice", (ulong)i);
+ store.SaveVersion("bob", (ulong)(100 - i));
+ });
+
+ Assert.Equal(99UL, store.GetVersion("alice"));
+ Assert.Equal(99UL, store.GetVersion("bob"));
+
+ store.Flush();
+
+ using var reloaded = new RuntimeStateStore(path, TimeSpan.Zero);
+ Assert.Equal(99UL, reloaded.GetVersion("alice"));
+ Assert.Equal(99UL, reloaded.GetVersion("bob"));
}
finally
{
@@ -36,7 +99,7 @@ public void StateStoreRejectsInvalidFile()
try
{
File.WriteAllText(path, """{"entries":[{"username":"alice","version":0}]}""", Encoding.UTF8);
- Assert.Throws(() => new RuntimeStateStore(path));
+ Assert.Throws(() => new RuntimeStateStore(path, TimeSpan.Zero));
}
finally
{
@@ -76,12 +139,18 @@ public void RecoveryWindowRestoresSnapshotAtPersistedVersion()
var path = Path.Combine(Path.GetTempPath(), $"textcascade-state-{Guid.NewGuid():N}.json");
try
{
- new RuntimeStateStore(path).SaveVersion("alice", 7UL);
+ using (var initialStore = new RuntimeStateStore(path, TimeSpan.Zero))
+ {
+ initialStore.SaveVersion("alice", 7UL);
+ initialStore.Flush();
+ }
+
var config = TextCascade.Server.Config.CreateDefaultConfig();
+ using var stateStore = new RuntimeStateStore(path, TimeSpan.Zero);
var server = new SyncServer(
config,
new UsersFile(),
- new RuntimeStateStore(path),
+ stateStore,
new Argon2PasswordHasher(),
new SystemClock(),
NullLogger.Instance);
@@ -111,12 +180,18 @@ public void RecoveryWindowIgnoresStaleSnapshot()
var path = Path.Combine(Path.GetTempPath(), $"textcascade-state-{Guid.NewGuid():N}.json");
try
{
- new RuntimeStateStore(path).SaveVersion("alice", 7UL);
+ using (var initialStore = new RuntimeStateStore(path, TimeSpan.Zero))
+ {
+ initialStore.SaveVersion("alice", 7UL);
+ initialStore.Flush();
+ }
+
var config = TextCascade.Server.Config.CreateDefaultConfig();
+ using var stateStore = new RuntimeStateStore(path, TimeSpan.Zero);
var server = new SyncServer(
config,
new UsersFile(),
- new RuntimeStateStore(path),
+ stateStore,
new Argon2PasswordHasher(),
new SystemClock(),
NullLogger.Instance);
@@ -137,6 +212,4 @@ public void RecoveryWindowIgnoresStaleSnapshot()
if (File.Exists(path)) File.Delete(path);
}
}
-}
-
-
+}
\ No newline at end of file
diff --git a/TextCascade.Server/RuntimeStateStore.cs b/TextCascade.Server/RuntimeStateStore.cs
index 2288ba1..13242c0 100644
--- a/TextCascade.Server/RuntimeStateStore.cs
+++ b/TextCascade.Server/RuntimeStateStore.cs
@@ -1,6 +1,8 @@
+using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging;
namespace TextCascade.Server;
@@ -8,40 +10,108 @@ public sealed record RuntimeStateEntry(string Username, ulong Version);
internal sealed record RuntimeStateFile(IReadOnlyList Entries);
-public sealed class RuntimeStateStore
+public sealed class RuntimeStateStore : IDisposable
{
- private readonly object gate = new();
private readonly string path;
- private readonly Dictionary versions;
+ private readonly ILogger? logger;
+ private readonly ConcurrentDictionary versions;
+ private int isDirty;
+ private readonly object writeGate = new();
- public RuntimeStateStore(string path)
+ private readonly PeriodicTimer? flushTimer;
+ private readonly CancellationTokenSource? cts;
+ private readonly Task? flushLoopTask;
+ private bool disposed;
+
+ public RuntimeStateStore(
+ string path,
+ TimeSpan? flushInterval = null,
+ ILogger? logger = null)
{
this.path = path;
- versions = Load(path);
+ this.logger = logger;
+ this.versions = new ConcurrentDictionary(Load(path), StringComparer.Ordinal);
+
+ var interval = flushInterval ?? TimeSpan.FromSeconds(5);
+ if (interval > TimeSpan.Zero)
+ {
+ flushTimer = new PeriodicTimer(interval);
+ cts = new CancellationTokenSource();
+ flushLoopTask = Task.Run(() => RunFlushLoopAsync(cts.Token));
+ }
}
public ulong GetVersion(string username)
{
- lock (gate)
+ return versions.TryGetValue(username, out var version) ? version : 0UL;
+ }
+
+ public void SaveVersion(string username, ulong version)
+ {
+ versions.AddOrUpdate(
+ username,
+ static (_, newVer) => newVer,
+ static (_, current, newVer) => newVer > current ? newVer : current,
+ version);
+ Volatile.Write(ref isDirty, 1);
+ }
+
+ public bool Flush()
+ {
+ if (Interlocked.Exchange(ref isDirty, 0) == 0)
+ {
+ return false;
+ }
+
+ lock (writeGate)
{
- return versions.TryGetValue(username, out var version) ? version : 0UL;
+ try
+ {
+ var entries = versions
+ .Select(pair => new RuntimeStateEntry(pair.Key, pair.Value))
+ .OrderBy(pair => pair.Username, StringComparer.Ordinal)
+ .ToList();
+ WriteAtomic(path, entries);
+ return true;
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
+ {
+ Volatile.Write(ref isDirty, 1);
+ logger?.LogWarning(exception, "Failed to write runtime state file; will retry in next flush cycle. path={Path}", path);
+ return false;
+ }
}
}
- public void SaveVersion(string username, ulong version)
+ private async Task RunFlushLoopAsync(CancellationToken cancellationToken)
{
- lock (gate)
+ if (flushTimer is null) return;
+ try
{
- if (versions.TryGetValue(username, out var current) && version <= current)
+ while (await flushTimer.WaitForNextTickAsync(cancellationToken))
{
- return;
+ Flush();
}
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ }
- versions[username] = version;
- WriteAtomic(path, versions.Select(pair => new RuntimeStateEntry(pair.Key, pair.Value))
- .OrderBy(pair => pair.Username, StringComparer.Ordinal)
- .ToList());
+ public void Dispose()
+ {
+ if (disposed) return;
+ disposed = true;
+
+ if (cts is not null)
+ {
+ cts.Cancel();
+ try { flushLoopTask?.GetAwaiter().GetResult(); } catch { }
+ cts.Dispose();
}
+
+ flushTimer?.Dispose();
+ Flush();
}
private static Dictionary Load(string path)
@@ -126,4 +196,4 @@ private static void WriteAtomic(string path, IReadOnlyList en
}
}
}
-}
+}
\ No newline at end of file
diff --git a/TextCascade.Server/SyncServer.cs b/TextCascade.Server/SyncServer.cs
index e5d1809..166d28f 100644
--- a/TextCascade.Server/SyncServer.cs
+++ b/TextCascade.Server/SyncServer.cs
@@ -267,6 +267,8 @@ public async Task ShutdownAsync(TimeSpan drain, DateTimeOffset nowUtc)
CancelConnection(connection, "server_shutdown");
}
}
+
+ runtimeStateStore.Flush();
}
private static async Task CloseConnectionAsync(ConnectionContext connection, WebSocketCloseStatus status, string reason)
diff --git a/TextCascade.Server/TextCascade.Server.csproj b/TextCascade.Server/TextCascade.Server.csproj
index c4e31a7..c1e075c 100644
--- a/TextCascade.Server/TextCascade.Server.csproj
+++ b/TextCascade.Server/TextCascade.Server.csproj
@@ -1,10 +1,10 @@
-
+
net10.0
enable
enable
- 0.3.0
+ 0.3.5
TextCascade.Server
true
From fb3386115fcd85bd84e8783d8c99d104fb253fb8 Mon Sep 17 00:00:00 2001
From: long45343 <1725334094@qq.com>
Date: Thu, 27 Aug 2026 23:44:35 +0800
Subject: [PATCH 21/32] Release v0.4.0: contract/network test suites, spec
alignment, CI split
- Contract tests: JSON sample corpus (duplicate/unknown fields, illegal
numbers, depth-4, invalid UTF-8) + byte-level serialization invariants
- NetworkIntegration category (12 cases) over real Kestrel TLS with
runtime self-signed certs: handshake, TLS 1.2/1.3 probes, frame
fragmentation, oversize/zero-length 1009 closes, restart with token
direct reconnect + persisted version baseline, snapshot election,
graceful-shutdown bye/1001
- SlowHash category: real Argon2 Hash/Verify/NeedsRehash chain
- Unit gap closure: token illegal-number forms, CLI watermark
allocation/recreate/overflow fail-fast, WithVersion, behavior-level
duplicate-id idempotency
- CI: SlowHash merged into main test job; NetworkIntegration runs in a
dedicated job; release workflow uses the same category filter
- docs/server-spec.md rewritten to match v0.3.5+ implementation with
implementation-gap ledger; added specs/test-and-contract-spec.md and
specs/spec-decisions.md
- Version 0.3.5 -> 0.4.0
---
.github/workflows/ci.yml | 27 +-
.github/workflows/release.yml | 4 +-
.gitignore | 3 +-
CHANGELOG.md | 14 +
README.md | 4 +-
TextCascade.Server.Tests/AuthDeepTests.cs | 161 ++++++
TextCascade.Server.Tests/CliWatermarkTests.cs | 273 +++++++++
.../ContractSamples/README.md | 29 +
.../invalid/depth-4/hello.deep-nesting.json | 17 +
.../invalid/depth-4/hello.root-depth.json | 19 +
.../invalid/duplicate-field/clip.id.json | 8 +
.../duplicate-field/hello.clientId.json | 7 +
.../invalid/duplicate-field/hello.type.json | 7 +
.../invalid/duplicate-field/pong.type.json | 5 +
.../number/clip.encrypted.string-bool.json | 7 +
.../invalid/number/clip.hash.number.json | 7 +
.../hello.lastserverversion.exponent.json | 6 +
.../hello.lastserverversion.fraction.json | 6 +
.../hello.lastserverversion.negative.json | 6 +
.../hello.lastserverversion.string.json | 6 +
.../hello.lastserverversion.too-large.json | 6 +
.../number/hello.snapshot.offset-time.json | 12 +
.../number/pong.clienttimeutc.no-z.json | 4 +
.../number/pong.clienttimeutc.number.json | 4 +
.../invalid/unknown-field/clip.version.json | 8 +
.../invalid/unknown-field/hello.extra.json | 7 +
.../invalid/unknown-field/pong.extra.json | 5 +
.../utf8/clip.payload-invalid-bytes.bin | 1 +
.../utf8/hello.clientid-lone-surrogate.bin | Bin 0 -> 75 bytes
.../invalid/utf8/pong.extra-invalid-bytes.bin | 1 +
.../ContractSamples/valid/clip.basic.json | 7 +
.../ContractSamples/valid/hello.full.json | 12 +
.../ContractSamples/valid/hello.minimal.json | 6 +
.../valid/hello.null-snapshot.json | 7 +
.../hello.snapshot-roundtrip-timestamp.json | 12 +
.../ContractSamples/valid/pong.ok.json | 4 +
.../ContractTests/ContractSampleTests.cs | 172 ++++++
.../ContractTests/ContractSchemaInvariants.cs | 113 ++++
.../IdempotencyBehaviorTests.cs | 203 +++++++
.../FrameFragmentationTests.cs | 255 +++++++++
.../NetworkIntegration/NetworkTestFixture.cs | 189 ++++++
.../RestartRecoveryTests.cs | 343 +++++++++++
.../TlsAndWssHandshakeTests.cs | 214 +++++++
.../SlowHashSmokeTests.cs | 63 ++
.../TextCascade.Server.Tests.csproj | 4 +
TextCascade.Server/TextCascade.Server.csproj | 2 +-
docs/server-spec.md | 539 +++++++-----------
specs/code-review.md | 81 +++
specs/spec-decisions.md | 218 +++++++
specs/test-and-contract-spec.md | 316 ++++++++++
50 files changed, 3081 insertions(+), 343 deletions(-)
create mode 100644 TextCascade.Server.Tests/AuthDeepTests.cs
create mode 100644 TextCascade.Server.Tests/CliWatermarkTests.cs
create mode 100644 TextCascade.Server.Tests/ContractSamples/README.md
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/depth-4/hello.deep-nesting.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/depth-4/hello.root-depth.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/clip.id.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/hello.clientId.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/hello.type.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/pong.type.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/number/clip.encrypted.string-bool.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/number/clip.hash.number.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.exponent.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.fraction.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.negative.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.string.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.too-large.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/number/hello.snapshot.offset-time.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/number/pong.clienttimeutc.no-z.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/number/pong.clienttimeutc.number.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/clip.version.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/hello.extra.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/pong.extra.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/utf8/clip.payload-invalid-bytes.bin
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/utf8/hello.clientid-lone-surrogate.bin
create mode 100644 TextCascade.Server.Tests/ContractSamples/invalid/utf8/pong.extra-invalid-bytes.bin
create mode 100644 TextCascade.Server.Tests/ContractSamples/valid/clip.basic.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/valid/hello.full.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/valid/hello.minimal.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/valid/hello.null-snapshot.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/valid/hello.snapshot-roundtrip-timestamp.json
create mode 100644 TextCascade.Server.Tests/ContractSamples/valid/pong.ok.json
create mode 100644 TextCascade.Server.Tests/ContractTests/ContractSampleTests.cs
create mode 100644 TextCascade.Server.Tests/ContractTests/ContractSchemaInvariants.cs
create mode 100644 TextCascade.Server.Tests/IdempotencyBehaviorTests.cs
create mode 100644 TextCascade.Server.Tests/NetworkIntegration/FrameFragmentationTests.cs
create mode 100644 TextCascade.Server.Tests/NetworkIntegration/NetworkTestFixture.cs
create mode 100644 TextCascade.Server.Tests/NetworkIntegration/RestartRecoveryTests.cs
create mode 100644 TextCascade.Server.Tests/NetworkIntegration/TlsAndWssHandshakeTests.cs
create mode 100644 TextCascade.Server.Tests/SlowHashSmokeTests.cs
create mode 100644 specs/code-review.md
create mode 100644 specs/spec-decisions.md
create mode 100644 specs/test-and-contract-spec.md
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4d44bde..374fe62 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,7 +28,10 @@ jobs:
run: dotnet build TextCascade.Server.slnx --configuration Release --no-restore
- name: Test
- run: dotnet test TextCascade.Server.slnx --configuration Release --no-build --logger trx --results-directory ./TestResults
+ run: >
+ dotnet test TextCascade.Server.slnx --configuration Release --no-build
+ --filter "Category!=NetworkIntegration"
+ --logger trx --results-directory ./TestResults
- name: Upload test results
if: always()
@@ -37,3 +40,25 @@ jobs:
name: test-results
path: ./TestResults
if-no-files-found: warn
+
+ network-tests:
+ name: Network integration tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+ dotnet-quality: ga
+
+ - name: Restore
+ run: dotnet restore TextCascade.Server.slnx
+
+ - name: Build
+ run: dotnet build TextCascade.Server.slnx --configuration Release --no-restore
+
+ - name: Test
+ run: dotnet test TextCascade.Server.slnx --configuration Release --no-build --filter "Category=NetworkIntegration"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 33a35cd..7aa67d5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -30,7 +30,9 @@ jobs:
run: dotnet build TextCascade.Server.slnx --configuration Release --no-restore
- name: Test
- run: dotnet test TextCascade.Server.slnx --configuration Release --no-build
+ run: >
+ dotnet test TextCascade.Server.slnx --configuration Release --no-build
+ --filter "Category!=NetworkIntegration"
- name: Resolve version
id: version
diff --git a/.gitignore b/.gitignore
index b145798..67c4a6d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
## 项目私有目录,不纳入版本库
-specs/
+!/specs/
+.zcode/
.trae/
## .NET 构建产物
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fd27d71..43f11f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.4.0] - 2026-08-27
+
+### Added
+- Contract test suite with JSON sample corpus (`ContractSamples/`) covering duplicate fields, unknown fields, illegal number forms, depth-4 nesting, and invalid UTF-8, plus byte-level serialization invariants for welcome/clip/ack/ping/error/token payloads.
+- Network integration test suite (`Category=NetworkIntegration`, 12 cases) over real Kestrel TLS with runtime-generated self-signed certificates: WSS handshake, TLS 1.2/1.3 protocol probes, random port binding, real frame fragmentation, oversize/zero-length frame closes (1009), server restart with token direct reconnect and persisted-version baseline, snapshot election restore, and graceful-shutdown bye/1001 chain.
+- Slow-hash smoke tests (`Category=SlowHash`) exercising the real Argon2 Hash/Verify/NeedsRehash chain with production parameters.
+- Unit tests closing spec §10.1 gaps: token duplicate-field/illegal-number/range rejection, CLI watermark allocation, delete-and-recreate watermark behavior, revoke and overflow fail-fast with byte-identical file preservation, `WithVersion` immutability, and behavior-level duplicate-id idempotency (drained token bucket still acks duplicates; reused id with new content treated as fresh message).
+
+### Changed
+- CI main test job now includes the SlowHash category and excludes only `Category=NetworkIntegration`, which runs in a dedicated CI job.
+- Release workflow test step aligned with the same category filter.
+- Server spec (`docs/server-spec.md`) rewritten to match v0.3.5 implementation: hot user-file reload, RuntimeStateStore version persistence, content-comparing clip idempotency semantics, 10-minute idle hub recycling, actual log event fields, and a new implementation-gap ledger (§15). Never-implemented items (benchmark project, `server_stop` event, performance target table) removed.
+- Added `specs/test-and-contract-spec.md` (function-level test and contract specification) and `specs/spec-decisions.md` (decision record for the spec alignment).
+
## [0.3.5] - 2026-08-22
### Changed
diff --git a/README.md b/README.md
index 02599e8..8eaed00 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@
| 密码哈希 | Argon2(id)(`Isopoh.Cryptography.Argon2`) |
| 用户存储 | `users.json` |
| 协议子协议 | `textcascade.v1` |
-| 产品版本 | SemVer,当前 `0.3.5` |
+| 产品版本 | SemVer,当前 `0.4.0` |
### 仓库结构
@@ -207,7 +207,7 @@ Built on ASP.NET Core Minimal API with native Kestrel WebSockets, TLS-terminated
| Password hash | Argon2(id) (`Isopoh.Cryptography.Argon2`) |
| User store | `users.json` |
| Subprotocol | `textcascade.v1` |
-| Version | SemVer, currently `0.3.5` |
+| Version | SemVer, currently `0.4.0` |
### Quick Start
diff --git a/TextCascade.Server.Tests/AuthDeepTests.cs b/TextCascade.Server.Tests/AuthDeepTests.cs
new file mode 100644
index 0000000..a61e07f
--- /dev/null
+++ b/TextCascade.Server.Tests/AuthDeepTests.cs
@@ -0,0 +1,161 @@
+using System.Security.Cryptography;
+using System.Text;
+using TextCascade.Server;
+
+namespace TextCascade.Server.Tests;
+
+public class AuthDeepTests
+{
+ private static readonly byte[] Secret = Encoding.UTF8.GetBytes(new string('k', 32));
+
+ private static UserRecord User(string username, long version) =>
+ new(username, "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$hash", version);
+
+ private static IReadOnlyDictionary Lookup(params UserRecord[] users) =>
+ users.ToDictionary(u => u.Username, u => u, StringComparer.Ordinal);
+
+ private static string CompactFromPayloadJson(string payloadJson)
+ {
+ var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
+ var signature = HMACSHA256.HashData(Secret, payloadBytes);
+ return Base64Url(payloadBytes) + "." + Base64Url(signature);
+ }
+
+ private static string Base64Url(byte[] bytes) =>
+ Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+
+ private static bool Verify(string payloadJson, TokenPayload? expected = null)
+ {
+ var token = CompactFromPayloadJson(payloadJson);
+ var now = DateTimeOffset.FromUnixTimeSeconds(1760000001);
+ return new TokenService(Secret).TryVerifyToken(token, now, Lookup(User("alice", 1)), out var actual)
+ && (expected is null || (actual.Subject == expected.Subject
+ && actual.Version == expected.Version
+ && actual.IssuedAtUnix == expected.IssuedAtUnix
+ && actual.ExpiresAtUnix == expected.ExpiresAtUnix));
+ }
+
+ // U1
+ [Fact]
+ public void SignToken_FieldOrder_And_MinimalJson()
+ {
+ var payload = new TokenPayload("alice", 1, 1760000000, 1762592000);
+ var compact = TokenService.SignToken(payload, Secret);
+ var payloadJson = Encoding.UTF8.GetString(Base64UrlDecode(compact.Split('.')[0]));
+
+ Assert.Equal("""{"sub":"alice","ver":1,"iat":1760000000,"exp":1762592000}""", payloadJson);
+ }
+
+ // U2
+ [Fact]
+ public void VerifyToken_Rejects_DuplicateFields()
+ {
+ Assert.False(Verify("""{"sub":"alice","ver":1,"ver":1,"iat":1760000000,"exp":1762592000}"""));
+ Assert.False(Verify("""{"sub":"alice","sub":"alice","ver":1,"iat":1760000000,"exp":1762592000}"""));
+ }
+
+ // U3
+ [Fact]
+ public void VerifyToken_Rejects_UnknownField()
+ {
+ Assert.False(Verify("""{"sub":"alice","ver":1,"iat":1760000000,"exp":1762592000,"aud":"x"}"""));
+ }
+
+ // U4
+ [Fact]
+ public void VerifyToken_Rejects_FractionNumber()
+ {
+ Assert.False(Verify("""{"sub":"alice","ver":1,"iat":1760000000.0,"exp":1762592000}"""));
+ Assert.False(Verify("""{"sub":"alice","ver":1.0,"iat":1760000000,"exp":1762592000}"""));
+ }
+
+ // U5
+ [Fact]
+ public void VerifyToken_Rejects_StringNumber()
+ {
+ Assert.False(Verify("""{"sub":"alice","ver":1,"iat":1760000000,"exp":"1762592000"}"""));
+ }
+
+ // U6
+ [Fact]
+ public void VerifyToken_Rejects_NegativeValue()
+ {
+ Assert.False(Verify("""{"sub":"alice","ver":-1,"iat":1760000000,"exp":1762592000}"""));
+ Assert.False(Verify("""{"sub":"alice","ver":1,"iat":-1760000000,"exp":1762592000}"""));
+ }
+
+ // U7
+ [Fact]
+ public void VerifyToken_Rejects_ExpNotAfterIat()
+ {
+ Assert.False(Verify("""{"sub":"alice","ver":1,"iat":1762592000,"exp":1760000000}"""));
+ Assert.False(Verify("""{"sub":"alice","ver":1,"iat":1760000000,"exp":1760000000}"""));
+ }
+
+ // U8
+ [Fact]
+ public void VerifyToken_Rejects_ZeroIat()
+ {
+ Assert.False(Verify("""{"sub":"alice","ver":1,"iat":0,"exp":1762592000}"""));
+ }
+
+ // U9
+ [Fact]
+ public void VerifyToken_RoundTrip_InstanceOverload()
+ {
+ var now = DateTimeOffset.FromUnixTimeSeconds(1760000000);
+ var service = new TokenService(Secret);
+ var token = service.CreateToken(User("alice", 1), now, TimeSpan.FromDays(30));
+
+ Assert.True(service.TryVerifyToken(token.CompactToken, now, Lookup(User("alice", 1)), out var payload));
+ Assert.Equal("alice", payload.Subject);
+ Assert.Equal(1, payload.Version);
+ Assert.Equal(1760000000, payload.IssuedAtUnix);
+ Assert.Equal(1762592000, payload.ExpiresAtUnix);
+ }
+
+ // U10
+ [Fact]
+ public void NeedsRehash_ParameterParsing()
+ {
+ var encoded = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aG9zdA";
+
+ Assert.False(Argon2PasswordHasher.NeedsRehash(encoded, 19456, 2, 1));
+ Assert.True(Argon2PasswordHasher.NeedsRehash(encoded, 1024, 2, 1));
+ Assert.True(Argon2PasswordHasher.NeedsRehash(encoded, 19456, 3, 1));
+ Assert.True(Argon2PasswordHasher.NeedsRehash(encoded, 19456, 2, 4));
+ Assert.True(Argon2PasswordHasher.NeedsRehash("", 19456, 2, 1));
+ Assert.True(Argon2PasswordHasher.NeedsRehash("$argon2i$v=19$m=19456,t=2,p=1$c2FsdA$aG9zdA", 19456, 2, 1));
+ Assert.True(Argon2PasswordHasher.NeedsRehash("not-a-hash", 19456, 2, 1));
+ }
+
+ // U11
+ [Fact]
+ public void WithVersion_Produces_NewImmutableRecord()
+ {
+ var original = new LatestText("payload", 7, "hash", true, "client", "name", new DateTimeOffset(2026, 8, 18, 8, 0, 0, TimeSpan.Zero));
+
+ var updated = CoreLogic.WithVersion(original, 8);
+ Assert.Equal(8UL, updated.Version);
+ Assert.Equal(original.Payload, updated.Payload);
+ Assert.Equal(original.Hash, updated.Hash);
+ Assert.Equal(original.Encrypted, updated.Encrypted);
+ Assert.Equal(original.FromClientId, updated.FromClientId);
+ Assert.Equal(original.FromClientName, updated.FromClientName);
+ Assert.Equal(original.UpdatedAtUtc, updated.UpdatedAtUtc);
+ Assert.Equal(7UL, original.Version);
+ Assert.NotSame(original, updated);
+
+ var withTime = CoreLogic.WithVersion(original, 9, new DateTimeOffset(2026, 8, 18, 9, 0, 0, TimeSpan.Zero));
+ Assert.Equal(9UL, withTime.Version);
+ Assert.Equal(new DateTimeOffset(2026, 8, 18, 9, 0, 0, TimeSpan.Zero), withTime.UpdatedAtUtc);
+ }
+
+ private static byte[] Base64UrlDecode(string segment)
+ {
+ var padded = segment.Replace('-', '+').Replace('_', '/');
+ var remainder = padded.Length % 4;
+ if (remainder > 0) { padded += new string('=', 4 - remainder); }
+ return Convert.FromBase64String(padded);
+ }
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/CliWatermarkTests.cs b/TextCascade.Server.Tests/CliWatermarkTests.cs
new file mode 100644
index 0000000..5a4bac1
--- /dev/null
+++ b/TextCascade.Server.Tests/CliWatermarkTests.cs
@@ -0,0 +1,273 @@
+using System.Text;
+using TextCascade.Server;
+
+namespace TextCascade.Server.Tests;
+
+public class CliWatermarkTests
+{
+ private const string ValidHash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aG9zdA";
+
+ private sealed class StaticPasswordHasher : IPasswordHasher
+ {
+ public string Hash(string password, Isopoh.Cryptography.Argon2.Argon2Config config) =>
+ "$argon2id$v=19$m=19456,t=2,p=1$" + Convert.ToBase64String(Encoding.UTF8.GetBytes(password).AsSpan(0, Math.Min(4, password.Length))) + "$" + Convert.ToBase64String("hashbytes"u8);
+
+ public bool Verify(string password, string encodedHash) => encodedHash == Hash(password, null!);
+
+ public bool NeedsRehash(string encodedHash, Isopoh.Cryptography.Argon2.Argon2Config config) => false;
+ }
+
+ private static string NewTempDir()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ ///
+ /// Runs the real CLI command set against a temp users.json. Password-consuming commands
+ /// (user add) must be wrapped with so --password-stdin reads the fed line.
+ ///
+ private static int WithStdin(string stdinLine, string[] args)
+ {
+ var original = Console.In;
+ try
+ {
+ Console.SetIn(new StringReader(stdinLine));
+ return Cli.RunCli(args, new StaticPasswordHasher());
+ }
+ finally
+ {
+ Console.SetIn(original);
+ }
+ }
+
+ private static UsersFile LoadUsers(string path) => UsersFile.LoadUsers(path);
+
+ private static void WriteUsersFile(string path, string json) => File.WriteAllText(path, json, Encoding.UTF8);
+
+ private static RuntimeConfig ConfigFor(string usersPath)
+ {
+ var config = TextCascade.Server.Config.CreateDefaultConfig();
+ return config with { Files = new FilesConfig(usersPath, Path.Combine(Path.GetDirectoryName(usersPath)!, "state.json")) };
+ }
+
+ // U13
+ [Fact]
+ public void AddUser_Allocates_FromWatermark_Increments()
+ {
+ var dir = NewTempDir();
+ try
+ {
+ var usersPath = Path.Combine(dir, "users.json");
+ WriteUsersFile(usersPath, $$"""
+ {
+ "nextTokenVersion": 7,
+ "users": [
+ {"username": "old", "passwordHash": "{{ValidHash}}", "tokenVersion": 3, "disabled": false}
+ ]
+ }
+ """);
+
+ var config = ConfigFor(usersPath);
+ var exit = WithStdin("test-password", ["user", "add", "--username", "newuser", "--password-stdin", "--config", ConfigPathFor(config)]);
+ Assert.Equal(Cli.Ok, exit);
+
+ var users = LoadUsers(usersPath);
+ var added = Assert.Single(users.Users, user => user.Username == "newuser");
+ Assert.Equal(7, added.TokenVersion);
+ Assert.Equal(8, users.NextTokenVersion);
+ Assert.Equal(3, users.Users.Single(user => user.Username == "old").TokenVersion);
+ }
+ finally
+ {
+ Directory.Delete(dir, true);
+ }
+ }
+
+ // U14
+ [Fact]
+ public void DeleteUser_RecreateSameName_GetsFreshHigherVersion()
+ {
+ var dir = NewTempDir();
+ try
+ {
+ var usersPath = Path.Combine(dir, "users.json");
+ WriteUsersFile(usersPath, $$"""
+ {
+ "nextTokenVersion": 5,
+ "users": [
+ {"username": "alice", "passwordHash": "{{ValidHash}}", "tokenVersion": 2, "disabled": false}
+ ]
+ }
+ """);
+
+ var config = ConfigFor(usersPath);
+ Assert.Equal(Cli.Ok, Cli.RunCli(["user", "delete", "--username", "alice", "--config", ConfigPathFor(config)], new StaticPasswordHasher()));
+ Assert.Empty(LoadUsers(usersPath).Users);
+
+ Assert.Equal(Cli.Ok, WithStdin("test-password", ["user", "add", "--username", "alice", "--password-stdin", "--config", ConfigPathFor(config)]));
+
+ var users = LoadUsers(usersPath);
+ var recreated = Assert.Single(users.Users);
+ Assert.Equal("alice", recreated.Username);
+ Assert.Equal(5, recreated.TokenVersion);
+ Assert.NotEqual(2, recreated.TokenVersion);
+ Assert.Equal(6, users.NextTokenVersion);
+ }
+ finally
+ {
+ Directory.Delete(dir, true);
+ }
+ }
+
+ // U15
+ [Fact]
+ public void RevokeTokens_Sets_Watermark_Increments()
+ {
+ var dir = NewTempDir();
+ try
+ {
+ var usersPath = Path.Combine(dir, "users.json");
+ WriteUsersFile(usersPath, $$"""
+ {
+ "nextTokenVersion": 9,
+ "users": [
+ {"username": "bob", "passwordHash": "{{ValidHash}}", "tokenVersion": 4, "disabled": false}
+ ]
+ }
+ """);
+
+ var config = ConfigFor(usersPath);
+ Assert.Equal(Cli.Ok, Cli.RunCli(["user", "revoke-tokens", "--username", "bob", "--config", ConfigPathFor(config)], new StaticPasswordHasher()));
+
+ var users = LoadUsers(usersPath);
+ Assert.Equal(9, users.Users.Single(user => user.Username == "bob").TokenVersion);
+ Assert.Equal(10, users.NextTokenVersion);
+ }
+ finally
+ {
+ Directory.Delete(dir, true);
+ }
+ }
+
+ // U16
+ [Fact]
+ public void AddUser_At_LongMaxWatermark_FailsFast_FileUnchanged()
+ {
+ var dir = NewTempDir();
+ try
+ {
+ var usersPath = Path.Combine(dir, "users.json");
+ var originalJson = $$"""
+ {
+ "nextTokenVersion": 9223372036854775807,
+ "users": [
+ {"username": "old", "passwordHash": "{{ValidHash}}", "tokenVersion": 1, "disabled": false}
+ ]
+ }
+ """;
+ WriteUsersFile(usersPath, originalJson);
+
+ var config = ConfigFor(usersPath);
+ Assert.Equal(Cli.Error, WithStdin("test-password", ["user", "add", "--username", "newuser", "--password-stdin", "--config", ConfigPathFor(config)]));
+
+ Assert.Equal(originalJson.ReplaceLineEndings(), File.ReadAllText(usersPath, Encoding.UTF8).ReplaceLineEndings());
+ Assert.Empty(Directory.GetFiles(dir, "*.tmp"));
+ }
+ finally
+ {
+ Directory.Delete(dir, true);
+ }
+ }
+
+ // U17
+ [Fact]
+ public void Revoke_At_LongMaxWatermark_FailsFast()
+ {
+ var dir = NewTempDir();
+ try
+ {
+ var usersPath = Path.Combine(dir, "users.json");
+ var originalJson = $$"""
+ {
+ "nextTokenVersion": 9223372036854775807,
+ "users": [
+ {"username": "bob", "passwordHash": "{{ValidHash}}", "tokenVersion": 1, "disabled": false}
+ ]
+ }
+ """;
+ WriteUsersFile(usersPath, originalJson);
+
+ var config = ConfigFor(usersPath);
+ Assert.Equal(Cli.Error, Cli.RunCli(["user", "revoke-tokens", "--username", "bob", "--config", ConfigPathFor(config)], new StaticPasswordHasher()));
+ Assert.Equal(originalJson.ReplaceLineEndings(), File.ReadAllText(usersPath, Encoding.UTF8).ReplaceLineEndings());
+ }
+ finally
+ {
+ Directory.Delete(dir, true);
+ }
+ }
+
+ // U18
+ [Fact]
+ public void ValidateUsers_NextMustExceed_AllUserVersions()
+ {
+ var users = new UsersFile { NextTokenVersion = 5, Users = new() { new("alice", ValidHash, 5) } };
+ Assert.Throws(() => UsersFile.ValidateUsers(users));
+ }
+
+ // U19
+ [Fact]
+ public void ValidateUsers_Rejects_NonPositiveVersion()
+ {
+ Assert.Throws(() =>
+ UsersFile.ValidateUsers(new UsersFile { NextTokenVersion = 5, Users = new() { new("alice", ValidHash, 0) } }));
+ Assert.Throws(() =>
+ UsersFile.ValidateUsers(new UsersFile { NextTokenVersion = 5, Users = new() { new("alice", ValidHash, -1) } }));
+ Assert.Throws(() =>
+ UsersFile.ValidateUsers(new UsersFile { NextTokenVersion = 0, Users = [] }));
+ }
+
+ // U20
+ [Fact]
+ public void SaveUsers_AtomicWrite_LeavesOriginal_OnValidationFailure()
+ {
+ var dir = NewTempDir();
+ try
+ {
+ var usersPath = Path.Combine(dir, "users.json");
+ WriteUsersFile(usersPath, $$"""
+ {
+ "nextTokenVersion": 5,
+ "users": [
+ {"username": "alice", "passwordHash": "{{ValidHash}}", "tokenVersion": 1, "disabled": false}
+ ]
+ }
+ """);
+ var originalContent = File.ReadAllText(usersPath, Encoding.UTF8);
+
+ var invalid = new UsersFile { NextTokenVersion = 5, Users = new() { new("alice", ValidHash, 5) } };
+ Assert.Throws(() => UsersFile.SaveUsers(usersPath, invalid));
+
+ Assert.Equal(originalContent.ReplaceLineEndings(), File.ReadAllText(usersPath, Encoding.UTF8).ReplaceLineEndings());
+ Assert.Empty(Directory.GetFiles(dir, "*.tmp"));
+ }
+ finally
+ {
+ Directory.Delete(dir, true);
+ }
+ }
+
+ private static string ConfigPathFor(RuntimeConfig config)
+ {
+ // Write a minimal TOML that pins users_file to the test path.
+ var path = Path.Combine(Path.GetDirectoryName(config.Files.UsersFile)!, "textcascade.toml");
+ File.WriteAllText(path, $"""
+ [files]
+ users_file = "{config.Files.UsersFile.Replace("\\", "\\\\")}"
+ state_file = "{config.Files.StateFile.Replace("\\", "\\\\")}"
+ """, Encoding.UTF8);
+ return path;
+ }
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/README.md b/TextCascade.Server.Tests/ContractSamples/README.md
new file mode 100644
index 0000000..282f7af
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/README.md
@@ -0,0 +1,29 @@
+# Contract Samples
+
+服务端协议契约样本,供三端(C# 服务端 / C# 桌面端 / Kotlin Android 端)对拍。
+
+## 目录语义(目录名即期望结果)
+
+| 目录 | 期望 |
+|---|---|
+| `valid/` | `ParseClientMessage` 成功,字段逐项匹配 |
+| `invalid/duplicate-field/` | 失败,`invalid_message` |
+| `invalid/unknown-field/` | 失败,`invalid_message` |
+| `invalid/number/` | 失败,`invalid_message`(含小数/指数/字符串数字/负数/超 ulong/类型污染/非 UTC 时间) |
+| `invalid/depth-4/` | 失败(MaxDepth=3),`invalid_message` |
+| `invalid/utf8/` | `.bin` 原始字节帧,失败,`invalid_message` |
+
+驱动器(ContractSampleTests)按一级子目录名推断期望错误码,缺省 `invalid_message`。
+
+## 无原生数值字段的等价覆盖说明
+
+`clip` 没有数值字段、`pong` 的 `clientTimeUtc` 是时间字符串,因此数字形态在这些消息上以"字段类型污染"等价覆盖(同一 Utf8JsonReader 数字/类型分支):
+
+- clip.encrypted 字符串化 → TryGetBoolean 分支
+- clip.hash 数字化 → TryGetString 分支
+- pong.clientTimeUtc 数字化 / 无 Z 后缀 → TryGetUtcDateTime 分支
+- hello.snapshot 带 +02:00 偏移 → 非零 Offset 拒绝分支
+
+## Token 直测样本
+
+token payload 的负数 / 小数 / 字符串数字样本内联于 `AuthDeepTests`(不走样本文件,因其直接调用 `TokenService.TryVerifyToken`)。
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/depth-4/hello.deep-nesting.json b/TextCascade.Server.Tests/ContractSamples/invalid/depth-4/hello.deep-nesting.json
new file mode 100644
index 0000000..1b7a508
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/depth-4/hello.deep-nesting.json
@@ -0,0 +1,17 @@
+{
+ "type": "hello",
+ "clientId": "windows-a",
+ "clientName": "",
+ "lastServerVersion": 0,
+ "snapshot": {
+ "payload": "clipboard text",
+ "encrypted": true,
+ "hash": "sha256-hex",
+ "localModifiedAtUtc": "2026-08-18T08:00:00Z",
+ "extra": {
+ "level": {
+ "tooDeep": true
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/depth-4/hello.root-depth.json b/TextCascade.Server.Tests/ContractSamples/invalid/depth-4/hello.root-depth.json
new file mode 100644
index 0000000..71c0ba4
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/depth-4/hello.root-depth.json
@@ -0,0 +1,19 @@
+{
+ "type": "hello",
+ "clientId": "windows-a",
+ "clientName": "",
+ "lastServerVersion": 0,
+ "snapshot": {
+ "payload": "text",
+ "encrypted": true,
+ "hash": "h",
+ "localModifiedAtUtc": "2026-08-18T08:00:00Z"
+ },
+ "l1": {
+ "l2": {
+ "l3": {
+ "l4": 1
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/clip.id.json b/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/clip.id.json
new file mode 100644
index 0000000..0b7c622
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/clip.id.json
@@ -0,0 +1,8 @@
+{
+ "type": "clip",
+ "id": "clip-1",
+ "id": "clip-2",
+ "payload": "text",
+ "encrypted": false,
+ "hash": "sha256-hex"
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/hello.clientId.json b/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/hello.clientId.json
new file mode 100644
index 0000000..bcbfa38
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/hello.clientId.json
@@ -0,0 +1,7 @@
+{
+ "type": "hello",
+ "clientId": "windows-a",
+ "clientId": "windows-b",
+ "clientName": "",
+ "lastServerVersion": 0
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/hello.type.json b/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/hello.type.json
new file mode 100644
index 0000000..5c61809
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/hello.type.json
@@ -0,0 +1,7 @@
+{
+ "type": "hello",
+ "type": "clip",
+ "clientId": "windows-a",
+ "clientName": "",
+ "lastServerVersion": 0
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/pong.type.json b/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/pong.type.json
new file mode 100644
index 0000000..1e3b08e
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/pong.type.json
@@ -0,0 +1,5 @@
+{
+ "type": "pong",
+ "type": "pong",
+ "clientTimeUtc": "2026-08-18T08:02:00Z"
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/clip.encrypted.string-bool.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/clip.encrypted.string-bool.json
new file mode 100644
index 0000000..d7cc238
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/clip.encrypted.string-bool.json
@@ -0,0 +1,7 @@
+{
+ "type": "clip",
+ "id": "clip-1",
+ "payload": "text",
+ "encrypted": "true",
+ "hash": "sha256-hex"
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/clip.hash.number.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/clip.hash.number.json
new file mode 100644
index 0000000..ffdabc4
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/clip.hash.number.json
@@ -0,0 +1,7 @@
+{
+ "type": "clip",
+ "id": "clip-1",
+ "payload": "text",
+ "encrypted": false,
+ "hash": 12345
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.exponent.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.exponent.json
new file mode 100644
index 0000000..fb50ca2
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.exponent.json
@@ -0,0 +1,6 @@
+{
+ "type": "hello",
+ "clientId": "windows-a",
+ "clientName": "",
+ "lastServerVersion": 1e3
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.fraction.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.fraction.json
new file mode 100644
index 0000000..e454838
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.fraction.json
@@ -0,0 +1,6 @@
+{
+ "type": "hello",
+ "clientId": "windows-a",
+ "clientName": "",
+ "lastServerVersion": 1.5
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.negative.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.negative.json
new file mode 100644
index 0000000..f3c03e0
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.negative.json
@@ -0,0 +1,6 @@
+{
+ "type": "hello",
+ "clientId": "windows-a",
+ "clientName": "",
+ "lastServerVersion": -1
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.string.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.string.json
new file mode 100644
index 0000000..ccda0f7
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.string.json
@@ -0,0 +1,6 @@
+{
+ "type": "hello",
+ "clientId": "windows-a",
+ "clientName": "",
+ "lastServerVersion": "128"
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.too-large.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.too-large.json
new file mode 100644
index 0000000..e05a470
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.too-large.json
@@ -0,0 +1,6 @@
+{
+ "type": "hello",
+ "clientId": "windows-a",
+ "clientName": "",
+ "lastServerVersion": 18446744073709551616
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.snapshot.offset-time.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.snapshot.offset-time.json
new file mode 100644
index 0000000..06ec3ba
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.snapshot.offset-time.json
@@ -0,0 +1,12 @@
+{
+ "type": "hello",
+ "clientId": "windows-a",
+ "clientName": "",
+ "lastServerVersion": 0,
+ "snapshot": {
+ "payload": "clipboard text",
+ "encrypted": true,
+ "hash": "sha256-hex",
+ "localModifiedAtUtc": "2026-08-18T10:00:00+02:00"
+ }
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/pong.clienttimeutc.no-z.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/pong.clienttimeutc.no-z.json
new file mode 100644
index 0000000..a1111f6
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/pong.clienttimeutc.no-z.json
@@ -0,0 +1,4 @@
+{
+ "type": "pong",
+ "clientTimeUtc": "2026-08-18T08:02:00"
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/pong.clienttimeutc.number.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/pong.clienttimeutc.number.json
new file mode 100644
index 0000000..52b10bf
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/pong.clienttimeutc.number.json
@@ -0,0 +1,4 @@
+{
+ "type": "pong",
+ "clientTimeUtc": 1760000000
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/clip.version.json b/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/clip.version.json
new file mode 100644
index 0000000..0a74774
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/clip.version.json
@@ -0,0 +1,8 @@
+{
+ "type": "clip",
+ "id": "clip-1",
+ "payload": "text",
+ "encrypted": false,
+ "hash": "sha256-hex",
+ "version": 12
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/hello.extra.json b/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/hello.extra.json
new file mode 100644
index 0000000..a56eac4
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/hello.extra.json
@@ -0,0 +1,7 @@
+{
+ "type": "hello",
+ "clientId": "windows-a",
+ "clientName": "",
+ "lastServerVersion": 0,
+ "extra": "not-allowed"
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/pong.extra.json b/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/pong.extra.json
new file mode 100644
index 0000000..be5a269
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/pong.extra.json
@@ -0,0 +1,5 @@
+{
+ "type": "pong",
+ "clientTimeUtc": "2026-08-18T08:02:00Z",
+ "extra": true
+}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/utf8/clip.payload-invalid-bytes.bin b/TextCascade.Server.Tests/ContractSamples/invalid/utf8/clip.payload-invalid-bytes.bin
new file mode 100644
index 0000000..cf9be42
--- /dev/null
+++ b/TextCascade.Server.Tests/ContractSamples/invalid/utf8/clip.payload-invalid-bytes.bin
@@ -0,0 +1 @@
+{"type":"clip","id":"clip-1","payload":"badÿþutf8","encrypted":false,"hash":"h"}
\ No newline at end of file
diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/utf8/hello.clientid-lone-surrogate.bin b/TextCascade.Server.Tests/ContractSamples/invalid/utf8/hello.clientid-lone-surrogate.bin
new file mode 100644
index 0000000000000000000000000000000000000000..5d9971dde6d9e70191d649f26280ba46080e9fb9
GIT binary patch
literal 75
zcmb InvalidSamples =>
+ Directory.GetFiles(Path.Combine(SamplesRoot, "invalid"), "*.*", SearchOption.AllDirectories)
+ .Select(path => new object[] { path });
+
+ public static IEnumerable