twebserver

TCL Web Server Extension

Contact: neophytos (at) gmail (dot) com

twebserver git repo

Releases

  • 2024-04-26 - Working on a permanent fix for macOS
  • v1.47.17 (latest, 2024-04-23 - fixed stupid mistake on macos)
  • v1.47.16 (2024-04-20 - moved listeners to threads + fixed issue in HandleRecv)
  • v1.47.15 (2024-03-30 - fixed mutex issue)
  • v1.47.14 (2024-03-14 - more TCL 9 support + ipv6_to_ipv4 cmd)
  • v1.47.13 (2024-03-10 - initial support for TCL 9)
  • v1.47.12 (2023-11-24 - get_rootdir without args to support upcoming templating engine example)
  • v1.47.11 (2023-11-18 - got rid of gc timer - conn cleanup now based on reqs count / thread)
  • v1.47.10 (2023-11-03 - added build_response and get_rootdir cmds, improved the example, fixed memleak in random_bytes cmd)
  • v1.47.9 (2023-11-02 - bug fix release)
  • v1.47.8 (2023-11-02 - added convenience cmds to get params from query, path, and header plus bug fix in add_cookie)
  • v1.47.7 (2023-10-30 - HTTP support in addition to HTTPS support)
  • v1.47.6 (2023-10-29 - non-blocking i/o + bug fixes + deprecated medium and low-level commands)
  • v1.47.5 (2023-10-28 - form handling, experimental non-blocking i/o, ipv6 support)
  • v1.47.4 (2023-10-23 - add_header, parse_cookie, add_cookie cmds)
  • v1.47.3 (2023-10-09 - routing and middleware functionality)
  • v1.47.2 (2023-09-28 - macOS support with kqueue mechanism & more flexible build/install steps)
  • v1.47.1 (2023-09-26 - epoll mechanism - linux)
  • v1.47.0 (2023-09-26 - initial release, was using select() mechanism)

Features

  • High performance web server (HTTP & HTTPS) written in C and Tcl.
  • It uses a highly efficient event-driven model with fixed number of threads to manage connections.
  • It can be easily extended.
  • It is a TCL loadable module.
  • It supports multiple certificates for different hosts (SNI).
  • Keepalive connections
  • Compression (gzip)
  • Routing & Middleware functionality

Acknowledgements

Many thanks to Holger Ewert for the constructive feedback.

Benchmarks

Here are some benchmarks with and without keepalive both for HTTPS and HTTP:

HTTPS

With keepalive - Linux - Intel i9 CPU @ 3.60GHz with 64GB RAM:

# gohttpbench -v 10 -n 100000 -c 10 -t 1000 -k "https://localhost:4433/blog/12345/sayhi"

Concurrency Level:      10
Time taken for tests:   1.17 seconds
Complete requests:      100000
Failed requests:        0
HTML transferred:       5200000 bytes
Requests per second:    85497.45 [#/sec] (mean)
Time per request:       0.117 [ms] (mean)
Time per request:       0.012 [ms] (mean, across all concurrent requests)
HTML Transfer rate:     4341.56 [Kbytes/sec] received

Without keepalive - Linux - Intel i9 CPU @ 3.60GHz with 64GB RAM:

# gohttpbench -v 10 -n 100000 -c 10 -t 1000 "https://localhost:4433/blog/12345/sayhi"

Concurrency Level:      10
Time taken for tests:   23.95 seconds
Complete requests:      100000
Failed requests:        0
HTML transferred:       5200000 bytes
Requests per second:    4175.56 [#/sec] (mean)
Time per request:       2.395 [ms] (mean)
Time per request:       0.239 [ms] (mean, across all concurrent requests)
HTML Transfer rate:     212.04 [Kbytes/sec] received

HTTP

With keepalive - Linux - Intel i9 CPU @ 3.60GHz with 64GB RAM:

# gohttpbench -v 10 -n 100000 -c 10 -t 1000 -k "http://localhost:8080/blog/12345/sayhi"

Concurrency Level:      10
Time taken for tests:   1.95 seconds
Complete requests:      100000
Failed requests:        0
HTML transferred:       5200000 bytes
Requests per second:    51320.23 [#/sec] (mean)
Time per request:       0.195 [ms] (mean)
Time per request:       0.019 [ms] (mean, across all concurrent requests)
HTML Transfer rate:     2606.04 [Kbytes/sec] received

Without keepalive - Linux - Intel i9 CPU @ 3.60GHz with 64GB RAM:

# gohttpbench -v 10 -n 100000 -c 10 -t 1000 "http://localhost:8080/blog/12345/sayhi"

Concurrency Level:      10
Time taken for tests:   9.44 seconds
Complete requests:      100000
Failed requests:        0
HTML transferred:       5200000 bytes
Requests per second:    10596.08 [#/sec] (mean)
Time per request:       0.944 [ms] (mean)
Time per request:       0.094 [ms] (mean, across all concurrent requests)
HTML Transfer rate:     538.07 [Kbytes/sec] received

Examples

See Threads & Routing & Middleware example

package require twebserver

set init_script {
    package require twebserver

    namespace eval simple_session_manager {
        proc enter {ctx req} {
            dict set req session [dict create id 1234567890]
            return $req
        }
        proc leave {ctx req res} {
            set res [::twebserver::add_cookie -maxage 3600 $res session_id [dict get $req session id]]
            return $res
        }
    }

    # create a router
    set router [::twebserver::create_router]

    # add middleware to the router
    ::twebserver::add_middleware \
        -enter_proc simple_session_manager::enter \
        -leave_proc simple_session_manager::leave \
        $router

    # add a route that will be called if the request method is GET and the path is "/"
    ::twebserver::add_route -strict $router GET / get_index_page_handler

    # add a route that has a path parameter called "user_id"
    # when the route path expression matches, it will call "get_blog_entry_handler" proc
    ::twebserver::add_route -strict $router GET /blog/:user_id/sayhi get_blog_entry_handler

    # add a route that will be called if the request method is GET and the path is "/addr"
    ::twebserver::add_route -strict $router GET /addr get_addr_handler

    # add a route that will be called if the request method is POST and the path is "/example"
    ::twebserver::add_route -strict $router POST /example post_example_handler

    # add a route that will be called if the request method is GET and the path is "/logo"
    ::twebserver::add_route -strict $router GET /logo get_logo_handler

    # add a catchall route that will be called if no other route matches a GET request
    ::twebserver::add_route $router GET "*" get_catchall_handler

    # make sure that the router will be called when the server receives a connection
    interp alias {} process_conn {} $router

    proc get_index_page_handler {ctx req} {
        set html {
            <html>
                <body>
                    <img src=/logo />
                    <h1>hello world</h1>
                    <ul>
                        <li><a href=/blog/123/sayhi>click here to see how path parameters work</a></li>
                        <a href=/addr>click here to see your IP address</a>
                    </ul>
                </body>
            </html>
        }
        set res [::twebserver::build_response 200 text/html $html]
        return $res
    }

    proc get_logo_handler {ctx req} {
        set server_handle [dict get $ctx server]
        set dir [::twebserver::get_rootdir $server_handle]
        set filepath [file join $dir plume.png]
        set res [::twebserver::build_response -return_file 200 image/png $filepath]
        return $res
    }

    proc get_catchall_handler {ctx req} {
        set res [::twebserver::build_response 404 text/plain "not found"]
        return $res
    }

    proc post_example_handler {ctx req} {
        set form [::twebserver::get_form $req]
        #puts form=$form

        # build the response dictionary
        set res [::twebserver::build_response 200 text/plain \
            "test message POST addr=[dict get $ctx addr] headers=[dict get $req headers] fields=[dict get $form fields]"

        return $res
    }

    proc get_blog_entry_handler {ctx req} {

        # get IP address of client from the context dictionary
        set addr [dict get $ctx addr]

        # get a boolean value from the context dictionary that indicates if the connection is secure
        # it should be true when you make HTTPS requests to the server, false for HTTP requests
        set isSecureProto [dict get $ctx isSecureProto]

        # get a path parameter from the request dictionary
        set user_id [::twebserver::get_path_param $req user_id]

        # build the response dictionary
        set res [::twebserver::build_response 200 text/plain \
            "test message GET user_id=$user_id addr=$addr isSecureProto=$isSecureProto"]
        return $res
    }

    proc get_addr_handler {ctx req} {
        set res [::twebserver::build_response 200 text/plain "addr=[dict get $ctx addr]"]
        return $res
    }

}

# use threads and gzip compression
set config_dict [dict create \
    num_threads 10 \
    rootdir [file dirname [info script]] \
    gzip on \
    gzip_types [list text/plain application/json] \
    gzip_min_length 20]

# create the server
set server_handle [::twebserver::create_server $config_dict process_conn $init_script]

# add SSL context to the server
::twebserver::add_context $server_handle localhost "../certs/host1/key.pem" "../certs/host1/cert.pem"
::twebserver::add_context $server_handle www.example.com "../certs/host2/key.pem" "../certs/host2/cert.pem"

# listen for an HTTPS connection on port 4433
::twebserver::listen_server $server_handle 4433

# listen for an HTTP connection on port 8080
::twebserver::listen_server -http $server_handle 8080

# print that the server is running
puts "server is running. go to https://localhost:4433/ or http://localhost:8080/"

# wait forever
vwait forever

# destroy the server
::twebserver::destroy_server $server_handle

HE 2023-09-04: I like the base idea of that extension. It looks like it takes all hassle away to write the same in plain Tcl by using TLS extension. That is why I directly compiled and tried it for Linux.

Here my experiences based on source files downloaded on 2023-08-31 by using main button Code->Download ZIP It claims it is version 1.0.0.

  • (solved) During compilation I found out that the SSL stuff comes from openssl, which needs to be installed. That should mentioned in the documentation as precaution. Also the openssl version needed in minimum should be told.
  • (solved) A small typo in readme.md. The line " - writes to a connection" misses some spaces in the beginning.
  • (solved) I tried the examples which worked only for localhost. That is understandable and I tried to create my own certificate. And failed.
  • Missing documentation about dictionary config_dict

Now to the part which made me unhappy because in that way the extension is not usable for me. Possibly I have overseen something. I would be happy to learn how to work around the following:

  • (solved)::twebserver::listen_server never comes back. Everything behind will never be executed.
  • (solved)::twebserver::listen_server blocks event loop
  • (solved)Errors are killing the application instead to be possible to be caught.

These three are all no goes from my point of expectation for a loadable module/package.

(solved) About "creating a certificate":

To mention is, I had no experience to create certificated. And my private computer is behind a public network access device from the company AVM. These use internally the domain fritz.box.

That means I wanted one certificate for:

  • localhost
  • foo
  • foo.fritz.box

And I found out that the following command successfully replace the three openssl commands from the documentation to get it running:

        # First go into the directory where the certficate should be stored.
        # In our case ./certs/host1
        openssl req -x509 \
        -newkey rsa:4096 \
        -keyout key.pem \
        -out cert.pem \
        -sha256 \
        -days 3650 \
        -nodes \
        -subj "/C=DE/ST=Germany/L=Home/O=none/OU=CompanySectionName/CN=localhost/CN=foo1/CN=foo1.fritz.box"

To use them I replace all "::twebserver::add_context" lines with:

::twebserver::add_context $server_handle localhost         "./certs/host1/key.pem" "./certs/host1/cert.pem"
::twebserver::add_context $server_handle holger9           "./certs/host1/key.pem" "./certs/host1/cert.pem"
::twebserver::add_context $server_handle holger9.fritz.box "./certs/host1/key.pem" "./certs/host1/cert.pem"

That worked with the single threaded and the multi threaded version.

(solved) About "::twebserver::listen_server never comes back. Everything behind will never be executed.":

That is a bit strange for a loadable module, which is part of something bigger.

Easy to test. Open a tclsh, copy and paste from the example code everything before ::twebserver::listen_server in it. You still get back a prompt.

Then execute the line with ::twebserver::listen_server and you will not get back a prompt.

Tests with curl shows that the server itself is running

(solved) About "::twebserver::listen_server blocks the event loop.":

I tried this with the single threaded and the multi threaded version.

That means even if the event loop is entered successful (if not the server would not be started) the blocking behaviour of ::twebserver::listen_server stops it. Therefore, that blocks the whole application. Only the HTTP server itself is running.

That is something I consider a real error because I can't go around it.

How to check it:

Replace the following line:

::twebserver::listen_server $server_handle 4433

with:

after 1 [list ::twebserver::listen_server $server_handle 4433]
after 100 {puts {A test output}}
vwait forever

That would start the server from the eventloop and puts another event on the loop which prints a message. The line "vwait forever" then starts the eventloop.

We can investigate that the server is started and works (curl tests are working). But we never see the test message. That is understandable because if a procedure do not come back the event loop will never be entered again.

And that is why the blocking of ::twebserver::listen_server is a critical issue from my point of view.

About "Errors are killing the application instead to be possible to be caught.": It looks like most (all?) commands of twebserver calls directly exit the program in case of an error instead of throwing an Tcl error which can be processed in the caller. For example the following code:

if {[catch {
        ::twebserver::add_context $server_handle localhost "../certs/host1/key.pem" "./certs/host1/cert.pem"
} err]} {
        puts $err
}

will run into an error because of the wrong path of cert.pem. Result is the output of:

404010CD8B7F0000:error:80000002:system library:file_ctrl:No such file or directory:crypto/bio/bss_file.c:297:calling fopen(./certs/host1/cert.pem, r)
404010CD8B7F0000:error:10080002:BIO routines:file_ctrl:system lib:crypto/bio/bss_file.c:300:
404010CD8B7F0000:error:0A080002:SSL routines:SSL_CTX_use_certificate_file:system  lib:ssl/ssl_rsa.c:291:

And tclsh is closed. That is not my expectation of the correct behavior of a loadable module.

HE 2023-09-17: I marked solved items from my last post with "(solved)" In addition I found the following findings in version downloaded on 2023-09-10):

  • Memory leak?
  • Error messages.
  • ::twebserver::return_conn raise segmentation fault
  • And the version I downloaded today is much slower than the version before.
  • How really to use HTTP keep alive?

About "Memory leak?": I used example.tcl simply with changed ::twebserver::add_context lines to match my certificate.

And I used another tclsh and copied the following into it:

package require http
package require tls
::http::register https 4433 [list ::tls::socket -autoservername true]
        proc testKeepalive1 {} {
        foreach el [list /probe/startup /probe/readiness /probe/liveness /metrics /de/da] {
                set url https://foo.fritz.box[set el]
                set token [::http::geturl $url -keepalive 1]
                ::http::cleanup $token
        }
        return
}
proc testKeepalive0 {} {
        foreach el [list /probe/startup /probe/readiness /probe/liveness /metrics /de/da] {
                set url https://foo.fritz.box[set el]
                set token [::http::geturl $url -keepalive 0]
                ::http::cleanup $token
        }
        return
}

Then I can use the following lines to bring load to the server:

time testKeepalive0 1000
time testKeepalive1 1000

A third console running the command top showed:

Tasks: 276 total,   2 running, 274 sleeping,   0 stopped,   0 zombie
%CPU(s): 17,0 us,  2,3 sy,  0,0 ni, 79,7 id,  0,0 wa,  0,4 hi,  0,6 si,  0,0 st 
MiB Spch:  15781,0 total,    239,5 free,  15388,4 used,    704,1 buff/cache     
MiB Swap:  17376,9 total,   5445,9 free,  11931,0 used.    392,6 avail Spch

PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     ZEIT+ BEFEHL           
2843 holger    20   0  537,0g  11,5g   6720 R  81,3  74,6  22:16.81 tclsh            
2354 holger    20   0   27392  12916   7396 S  20,0   0,1   4:20.40 tclsh 

The first tclsh line is the server. The columns VIRT and RES are increasing with every test run. And never shrink. After a couple of dozens tests this lead to a server crash.

About "Error messages:" Some errors which possibly should be handled:

::twebserver::add_context doesn't catch not existing handle. Where is the context added?

package require twebserver
set server_handle {}
::twebserver::add_context $server_handle localhost "../certs/host1/key.pem" "../certs/host1/cert.pem"

On the other hand, ::twebserver::listen_server does it correct:

::twebserver::listen_server $server_handle  4433
#=> server handle not found

And ::twebserver::destroy_server does it correct but use a different error text

::twebserver::destroy_server {}
#=> handle not found

By the way an empty host name leads also not to an error. I'm not sure if this could be an issue.

About "::twebserver::return_conn raise segmentation fault": It looks like ::twebserver::return_conn doesn't catch errors with the response_dict correctly. Instead I got a segmentation error.

Easiest way to simulate it: Change line "::twebserver::return_conn $conn $response_dict" to "::twebserver::return_conn $conn {}" in example-with-req-resp.tcl. Then start the example server.

With the first request the server prints:

error: statusCode not found
Speicherzugriffsfehler (Speicherabzug geschrieben)

and stops.

"Speicherzugriffsfehler (Speicherabzug geschrieben)" means "Segmentation Fault (Dump written)".

About "And the version I downloaded today is much slower than the version before": Same condition as in 'About "Memory leak?"'.

I got the following result:

% time testKeepalive0 1000
267731.312 microseconds per iteration
% time testKeepalive1 1000
137091.769 microseconds per iteration

Before I got:

% time testKeepalive0 1000
37643.129 microseconds per iteration
% time testKeepalive1 1000
23941.839 microseconds per iteration

That is between 5 and 7 times slower than before. Is there an explanation for that?

About "How really to use HTTP keep alive?": The above test use -keepalive 1 and -keepalive 0. In the header received by the server and after reply by the client showed Connection: keep-alive.

But the next request from the same client shows a different socket.

I don't believe the issue is on the client side because the server needs control over that.

The example server calls ::twebserver::close_conn. That means, it will not keep the connection alive.

But, if we remove it or control it by a timeout, the question comes up how to get the next request from the connection. For example if we use ::twebserver::parse_conn how we know that there is a new request fully received? Or, if we use ::twebserver::read_conn (which would mean we have to find out by ourselve when we received a full request) how we know that there are new data to read. Without that, we can't go in an asynchronous mode to receive more than one request on the same connection. Possibly I missed something.

And as a last item in the list: I would really like to have some documentation about the request dict and the response dict.

The documentation of twebserver doesn't describe how

neophytosd 2023-09-18: Some quick replies until I get a chance to review all of the feedback:

  • Memory leak? => Updated 2023-09-22: Fixed.
  • Error messages => status code is required in response dict. Updated 2023-09-22: Errors should be handled now. Not sure if I missed any.
  • ::twebserver::return_conn raise segmentation fault => Fixed
  • And the version I downloaded today is much slower than the version before. => Second SSL_shutdown was taking took long. Should be fine now. Updated 2023-09-22: I get 20 microseconds per request (mean, across all concurrent requests when keepalive is on) for the req-resp example now. Threads example is faster at 10 microseconds per request but I want to check it a bit more as it feels like cheating (it is too fast).
  • How really to use HTTP keep alive? => UPDATED 2023-09-20: An initial implementation of keepalive is in main branch and it works with and without threads. UPDATED 2023-09-22: The work on keepalive is concluded. The way it works is that it marks the connection as keepalive while parsing the request (with parse_conn). I could provide a keepalive_conn command to be used with read_conn/write_conn but I am concerned it opens the door for misuse (even when misused the garbage collector will collect those connections but still better to use parse_conn/return_conn instead of the low-level read_conn/write_conn). Please let me know what you think.
  • Documentation is updated. Configuration dictionary parameters have also been explained. Tests are pending.
  • I could not get tcltls extension to work for me.