- ymawky เป็น static HTTP server ขนาดเล็กสำหรับ macOS ที่เขียนด้วย aarch64 assembly ล้วน ๆ และใช้เฉพาะ Darwin raw syscalls โดยไม่พึ่ง libc wrappers
- รองรับ
GET,HEAD,PUT,OPTIONS,DELETE, คำขอ byte range, การแสดงรายการไดเรกทอรี, และหน้า error แบบกำหนดเอง แต่ไม่ได้ตั้งใจให้มาแทน nginx หากเป็นการตัดชั้นความสะดวกต่าง ๆ ออกเพื่อทำความเข้าใจว่าเว็บเซิร์ฟเวอร์ทำงานอย่างไร - ตั้งแต่การ parse request, percent-decoding, ตรวจสอบ header, แปลงค่า range, จัดการข้อผิดพลาด, ปิดไฟล์, ไปจนถึงสร้าง response ล้วนต้องเขียนเองทั้งหมด และแม้แต่งานที่ใน Python ทำได้ด้วยการแยกสตริงง่าย ๆ หรือ
int(string)ก็กลายเป็นโค้ดตรวจสอบยาวหลายสิบถึงหลายร้อยบรรทัดเมื่ออยู่ใน assembly - เซิร์ฟเวอร์ใช้โครงสร้าง fork-on-request ที่เรียก
fork()ทุกครั้งเมื่อมีการเชื่อมต่อใหม่ ทำให้เขียนง่าย แต่รองรับการเชื่อมต่อพร้อมกันได้ไม่สูง และอาจเสี่ยงต่อ slowloris จึงมีการใช้ header timeout และ body timeout ตามContent-Length PUTจะเขียนลงไฟล์ชั่วคราวชื่อ.ymawky_tmp_<pid>ก่อน แล้วค่อยสลับแทนที่เมื่อสำเร็จ พร้อมจัดการความปลอดภัยของไฟล์ระบบเอง เช่น การป้องกัน path traversal,O_NOFOLLOW_ANY,fstat64(), รวมถึง URL encoding และ HTML escaping ในการแสดงรายการไดเรกทอรี
ภาพรวมและข้อจำกัดของ ymawky
- ymawky เป็น static HTTP server ขนาดเล็กสำหรับ macOS ที่เขียนด้วย aarch64 assembly เท่านั้น
- ใช้เฉพาะ Darwin raw syscalls โดยไม่พึ่ง libc wrappers และไม่ใช้ไลบรารีภายนอกหรือ parser ที่มีอยู่แล้ว
- ฟีเจอร์ที่รองรับคือ
GET,HEAD,PUT,OPTIONS,DELETE, คำขอ byte range, การแสดงรายการไดเรกทอรี และหน้า error แบบกำหนดเอง - ข้อจำกัดของโปรเจกต์มีดังนี้
- aarch64 assembly only
- รองรับเฉพาะ macOS/Darwin
- ใช้ raw syscalls only, ไม่มี libc wrappers
- รองรับเฉพาะ static files
- ไม่มี preexisting parsers
- ไม่มี external libraries
- ไม่ได้มีเป้าหมายเพื่อแทนที่ nginx แต่เป็นการตัดชั้น abstraction ที่อำนวยความสะดวกออก เพื่อให้เข้าใจว่าเว็บเซิร์ฟเวอร์ทำงานจริงอย่างไร
งานที่ต้องทำเมื่อสร้างเว็บเซิร์ฟเวอร์ด้วย assembly
- assembly คือชั้นกลางระหว่าง machine code กับภาษาโปรแกรมระดับสูง โดยคำสั่งอย่าง
mov,add,ldr,str,cmpจะสอดคล้องกับไบต์ใน binary ที่รันจริงโดยตรง svc #0x80คือรูปแบบที่มนุษย์อ่านได้ของไบต์D4 00 10 01ใน binary ที่รันจริง- ไม่มีชนิดข้อมูล string ดังนั้นสตริงจึงเป็นเพียงพื้นที่ของไบต์ที่เรียงต่อกันในหน่วยความจำ และก็ไม่มีฟีเจอร์ภาษาอย่าง
structของ C ทำให้ต้องรู้ค่า field offset และขนาดรวมทั้งหมดด้วยตัวเอง - เพราะไม่มี HTTP library, automatic cleanup, exception หรือ object งานอย่าง parse request, จัดการข้อผิดพลาด, ปิดไฟล์, และสร้าง response จึงต้องเขียนเองทั้งหมด
- ถึงโค้ดจะทำงานผิด CPU ก็จะรันต่อไปโดยไม่เตือน ดังนั้นปัญหาจึงอยู่ที่คำสั่งและการเข้าถึงหน่วยความจำที่เราเขียนเอง
raw syscalls และลำดับการทำงานของเซิร์ฟเวอร์
-
Darwin system calls
- ymawky เรียก kernel โดยตรงแทนการใช้ libc wrappers
- บน Darwin aarch64 หมายเลข system call จะใส่ไว้ในรีจิสเตอร์
x16ขณะที่บน Linux aarch64 จะใส่ในx8 - หมายเลข system call ของ
open()คือ5และต้องจัดวางอาร์กิวเมนต์อย่างชื่อไฟล์กับโหมดลงในรีจิสเตอร์โดยตรงก่อนเรียก kernel ด้วยsvc #0x80 - ถ้า
open()ล้มเหลว carry flag จะถูกตั้งค่าไว้ และสามารถตรวจสอบแล้วกระโดดไปยังโค้ดจัดการความล้มเหลวได้ด้วยรูปแบบอย่างb.cs open_failed
-
การทำงานพื้นฐานของเซิร์ฟเวอร์
- ลำดับพื้นฐานของเว็บเซิร์ฟเวอร์คือรับ request, ประมวลผล, แล้วส่งกลับ status code และไฟล์ที่จำเป็น
- การตั้งค่า socket ประกอบด้วยขั้นตอนอย่าง
socket(AF_INET, SOCK_STREAM, 0),setsockopt(... SO_REUSEADDR ...),bind(sockfd, &addr, 16),listen(sockfd, 5),accept(sockfd, NULL, NULL) - ymawky เป็นเซิร์ฟเวอร์แบบ fork-on-request ที่เรียก
fork()ทุกครั้งเมื่อมีการเชื่อมต่อใหม่ - วิธีนี้ไม่แชร์หน่วยความจำระหว่างการประมวลผลแต่ละ request จึงเข้าใจและเขียนได้ง่าย แต่เพราะแต่ละโปรเซสมีพื้นที่หน่วยความจำของตนเอง ภาระจึงสูงกว่า และรองรับการเชื่อมต่อพร้อมกันได้น้อยกว่าโมเดล event-driven asynchronous non-blocking แบบ nginx
- เมื่อจำนวนการเชื่อมต่อพร้อมกันเพิ่มขึ้น kernel ก็จะใช้เวลากับการสลับโปรเซสมากกว่าการรันภายในแต่ละโปรเซส
-
งานที่ต้องทำระหว่างการประมวลผล request
- ต้องตรวจว่า request method เป็น
GET,HEAD,OPTIONS,PUT, หรือDELETE - ต้องดึง path ของ request แล้วถอดรหัส percent-encoding อย่าง
%20 - ต้องตรวจสอบความปลอดภัยของ path และ parse header fields ที่ client ส่งมา
- ต้องดึงข้อมูลไฟล์ของ request เพื่อแยกว่าเป็นไดเรกทอรีหรือไฟล์ปกติ
- request body ของ
PUTต้องเขียนลงไฟล์ชั่วคราว และต้องสร้าง response headers กับ body - ต้องปิดไฟล์ที่เปิดไว้และจัดการ error เพื่อไม่ให้เซิร์ฟเวอร์ crash
- ต้องตรวจว่า request method เป็น
ลงมือเขียน HTTP parser เอง
-
request line และจุดสิ้นสุดของ header
- HTTP request เป็นสตริงที่เซิร์ฟเวอร์ต้องตีความ ตัวอย่างเช่น
GET /index.html HTTP/1.0\r\n Range: bytes=1-5\r\n\r\n - บรรทัดแรกประกอบด้วย request
GET, ไฟล์เป้าหมายindex.htmlและเวอร์ชัน HTTPHTTP/1.0 \r\nคือจุดสิ้นสุดของบรรทัด และ\r\n\r\nคือจุดสิ้นสุดของ header- หากยังไม่ได้รับ
\r\n\r\nต้องหยุดและตอบกลับ400 Bad Request
- HTTP request เป็นสตริงที่เซิร์ฟเวอร์ต้องตีความ ตัวอย่างเช่น
-
การดึง path
- ymawky จะตรวจชนิดของ request จากเมธอดที่รองรับและไบต์เริ่มต้น จากนั้นจึงดึง path ออกมา
- มันจะสแกน header ทีละไบต์เพื่อหา
/หรือ*แต่จะตรวจด้วยว่าไบต์ก่อนหน้า/ต้องเป็นช่องว่าง เพื่อไม่ให้เข้าใจ/ในHTTP/1.0ว่าเป็น path - ตัวอย่างเช่น
GET HTTP/1.0\r\n\r\nมี/อยู่ในHTTP/1.0ดังนั้นถ้าไบต์ก่อนหน้าไม่ใช่ช่องว่าง ก็ต้องตอบ400 Bad Request - ในหลายระบบ
PATH_MAXมีค่า 4096 ไบต์ ดังนั้น ymawky จึงมีบัฟเฟอร์ชื่อไฟล์ขนาด 4096 ไบต์ พร้อมอีก 1 ไบต์สำหรับ null terminator ด้วยfilename_buffer: .skip 4097 - หาก path ของ request ยาวเกินบัฟเฟอร์ ต้องตอบ
414 URI Too Longแทนการเขียนทับหน่วยความจำแบบสุ่ม - งานที่ใน Python ใกล้เคียงกับ
text.split("GET /")[1].split(" ")[0]เมื่ออยู่ใน assembly และต้องรวมการตรวจสอบความถูกต้องของ HTTP ด้วย จะกลายเป็นโค้ดราว 200 บรรทัด
-
percent-decoding และการตรวจ header fields
- เมื่อพบ
%ใน path ต้องตรวจว่าอีกสองไบต์ถัดไปเป็นเลขฐานสิบหกที่ถูกต้องในช่วง0-9,a-f,A-Fแล้วจึงแปลงเป็นค่าไบต์นั้น GETอาจมี headerRange:และPUTจำเป็นต้องมีContent-Length:- header เหล่านี้ไม่ได้อยู่ในตำแหน่งคงที่เหมือน URL ของ request จึงต้องวนดู header ทั้งหมดทีละตัวอักษร
- หากมี
\rที่ไม่ตามด้วย\nหรือมี\nโดยไม่มี\rมาก่อน ต้องถือว่า header ไม่ถูกต้องและตอบ400 Bad Request - หากบรรทัด header ใหม่ขึ้นต้นด้วยช่องว่าง ต้องตอบ
400 Bad Requestเพราะ header field ไม่ควรเริ่มด้วยช่องว่าง
- เมื่อพบ
-
การเปรียบเทียบสตริงและแปลงตัวเลข
- เพื่อหา
Range:หรือContent-Length:จึงต้องเขียนฟังก์ชันstreqnที่รับ string pointer สองตัวคือx0,x1และความยาวสูงสุดx2แล้วเปรียบเทียบทีละตัวอักษร - header
Range:สามารถละค่าเริ่มต้นหรือค่าสิ้นสุดได้แบบนี้ แต่ต้องมีอย่างน้อยหนึ่งค่าเสมอRange: bytes=10- Range: bytes=-10 Range: bytes=5-10 - เพราะค่าช่วงเป็นสตริง จึงต้องมีฟังก์ชันสไตล์
atoiสำหรับแปลงเลข ASCII เป็นจำนวนเต็ม - เพื่อหลีกเลี่ยง overflow ของรีจิสเตอร์ 64 บิต หากตัวเลขยาวตั้งแต่ 19 หลักขึ้นไปจะถือเป็น error
- แม้งานอย่าง
int(string)ใน Python ก็ยังต้องลงมือเขียนเองใน assembly ทั้งการตรวจตัวเลข การคูณ การบวก และการใช้ carry flag เพื่อส่งสัญญาณว่าสำเร็จหรือล้มเหลว
- เพื่อหา
การจัดการ PUT และกลยุทธ์ไฟล์ชั่วคราว
PUTเป็นเมธอดแบบ idempotent ที่แม้ส่ง request เดิมหลายครั้ง สถานะสุดท้ายของเซิร์ฟเวอร์ก็ยังเหมือนเดิมPUT /file.txtจะสร้างfile.txtหรือเขียนทับไฟล์เดิมทั้งหมด และแม้ส่ง1234สองครั้ง เนื้อหาไฟล์ก็จะเป็น1234ไม่ใช่12341234- การเปิด
PUTแบบทั่วทั้งระบบอาจเสี่ยง และปัญหาที่ต้องคำนึงระหว่างประมวลผลมีดังนี้- กรณีโปรเซสล่มระหว่างจัดการ request
- กรณี client ระบุ
Content-Lengthว่า 2KB แต่ส่งมาเพียง 100 ไบต์ - กรณี client ระบุ
Content-Lengthมหาศาล เช่น 50GB
MAX_BODY_SIZEในconfig.Sมีค่าเริ่มต้น 1GB และหากContent-Lengthเกินค่านี้ จะตอบ413 Content Too Large- หากเปิดไฟล์เดิมแล้วเขียนลงไปทันที เมื่อเกิดความผิดพลาดอาจเหลือไฟล์ที่ถูกเขียนค้างไว้เพียงครึ่งเดียว ดังนั้น ymawky จึงเขียนลงไฟล์ชั่วคราวรูปแบบ
.ymawky_tmp_<pid>ก่อน - มันใช้ system call
getpid()หมายเลข20เพื่อเอา pid แล้วแปลงเป็นสตริงด้วยitoa()ที่เขียนเอง พร้อมตรวจบัฟเฟอร์ล้น - เมื่อเขียน request body จาก client ลงไฟล์ชั่วคราวครบและสำเร็จแล้ว จึงเปลี่ยนชื่อไฟล์ชั่วคราวให้เป็นชื่อปลายทาง เพื่อให้ไฟล์ที่ขอปรากฏบนเซิร์ฟเวอร์
- หาก client ตัดการเชื่อมต่ออย่างไม่คาดคิด, timeout, หรือส่ง body ที่ไม่ถูกต้อง ไฟล์ชั่วคราวจะถูกลบด้วย system call
unlink()หมายเลข10หรือunlinkat()หมายเลข472 - ไฟล์เดิมจะถูกเขียนทับก็ต่อเมื่อ request ที่สมบูรณ์ถูกส่งสำเร็จแล้วเท่านั้น
การแสดงรายการไดเรกทอรีและการ escape
- เมื่อได้รับ request
GET /somedir/จะตรวจว่ามีการเปิดALLOW_DIR_LISTINGในconfig.Sหรือไม่ - หากปิดการแสดงรายการไดเรกทอรีไว้ จะตอบ
403 Forbidden - หากเปิดไว้ จะใช้ system call
getdirentries64()หมายเลข344เพื่อเติมบัฟเฟอร์ด้วยข้อมูลไฟล์ในไดเรกทอรีที่ร้องขอ - ในบัฟเฟอร์จะมีชื่อไฟล์แต่ละรายการและความยาวชื่อไฟล์ ซึ่ง ymawky นำมาใช้สร้าง HTML ที่คลิกได้
- รูปแบบพื้นฐานที่ส่งให้ client สำหรับแต่ละไฟล์คือ
<a href="filename">filename</a> - ชื่อไฟล์ใน
href="..."ต้องถูก percent-encode ในฐานะ URL path segment ส่วนข้อความที่มองเห็นบนหน้าจอ ต้องถูก HTML-escape - หากชื่อไฟล์เป็น
&.-~><fooค่า href จะเป็น%26.-~%3E%3Cfooและข้อความที่แสดงจะเป็น&.-~><fooทำให้ผลลัพธ์สุดท้ายเป็นดังนี้<a href="%26.-~%3E%3Cfoo">&.-~><foo</a> - ด้วยวิธีนี้ ชื่อไฟล์อย่าง
<script>something evil</script>ที่อาจทำ XSS ในส่วนเนื้อหา หรือชื่ออย่าง\"><script>something dastardly</script>ที่อาจทำ XSS ในส่วนhref="..."ก็จะไม่ถูกรัน
ความปลอดภัยเครือข่ายและ timeout
- slowloris คือการโจมตีแบบปฏิเสธการให้บริการที่เปิดการเชื่อมต่อจำนวนมากค้างไว้โดยไม่ส่ง request ให้จบ เพื่อยึดทรัพยากรของเซิร์ฟเวอร์ไว้
- ymawky ใช้โครงสร้าง fork-on-request จึงอาจเปราะบางต่อ slowloris
- หากไม่ได้รับ header ครบภายใน
HEADER_REQ_TIMEOUT_SECSในconfig.Sจะส่ง408 Request Timeoutแล้วปิดการเชื่อมต่อ - หากระหว่างรับ request body client ส่งข้อมูลช้าเกินไป ก็จะจัดการแบบเดียวกันตามค่า
RECV_TIMEOUTในconfig.S - การตั้ง timeout แยกตามการอ่านแต่ละครั้งอย่างเดียวไม่เพียงพอ
- client ที่ประสงค์ร้ายอาจส่ง
Content-Length: 1073741823แล้วส่งมา 1 ไบต์ทุก 9 วินาที ซึ่งความยาวนี้ยังน้อยกว่าค่าสูงสุดอยู่ 1 ไบต์ จึงผ่านเงื่อนไขได้ และถ้า timeout เป็นทุก 10 วินาทีก็อาจต้องรอนานเกิน 300 ปี
- client ที่ประสงค์ร้ายอาจส่ง
- เพื่อลดปัญหานี้ ymawky จะคำนวณ timeout จาก
Content-Lengthและจำนวนไบต์ขั้นต่ำต่อวินาทีtimeout = grace_period + content_length / min_bps grace_periodคือเวลาขั้นต่ำที่ให้กับ body ทุกก้อน และmin_bpsคือความเร็วส่งข้อมูลต่ำสุดที่เซิร์ฟเวอร์ยอมรับ- ค่าเริ่มต้นของ
min_bpsคือ 16KB/s ซึ่งถือว่าผ่อนปรนแต่ไม่ไร้ขีดจำกัด - วิธีนี้ไม่ได้หยุดการโจมตีแบบปฏิเสธการให้บริการได้ทั้งหมด แต่ช่วยจำกัดเวลาที่ทรัพยากรถูกยึดไว้จากการโจมตีบางรูปแบบได้
ความปลอดภัยของไฟล์ระบบ
-
ลำดับการตรวจข้อมูลไฟล์
- สำหรับ
GETและHEADymawky จะเปิด path ที่ร้องขอก่อน แล้วจึงเรียก system callfstat64()หมายเลข339กับ file descriptor เพื่อดึงข้อมูลอย่างชนิดไฟล์และขนาด - หากเรียก
stat64()หมายเลข338กับ path ก่อน แล้วค่อยเปิดไฟล์ภายหลัง อาจเกิด TOCTOU race condition ที่ไฟล์ถูกเปลี่ยนไประหว่างจังหวะตรวจสอบกับจังหวะใช้งาน
- สำหรับ
-
docroot และการป้องกัน path traversal
- ทุก path ของ request จะถูกเติม docroot ไว้ข้างหน้า
- ค่า docroot เริ่มต้นคือ
www/ตามDEFAULT_DIRในconfig.S - request
/etc/shadowจึงกลายเป็นwww/etc/shadowและจะได้ 404 เว้นแต่www/etc/shadowจะมีอยู่จริง - แต่
/../../../../etc/shadowจะกลายเป็นwww/../../../../etc/shadowซึ่งอาจถูกตีความให้ออกนอก docroot ได้ จึงต้องมีการป้องกันเพิ่ม - ymawky ไม่ได้ปฏิเสธทุก path ที่มีสตริง
..แบบเหมารวม แต่จะปฏิเสธเมื่อ segment ของ path เป็น..ตรง ๆ %2E%2Eจะกลายเป็น..หลังถอดรหัส ดังนั้นการตรวจนี้ต้องทำหลัง percent-decoding
-
การจัดการ symbolic link
- ใน POSIX แฟลก
O_NOFOLLOWจะทำให้open()ล้มเหลว หากองค์ประกอบสุดท้ายของ path เป็น symbolic link - ส่วน
O_NOFOLLOW_ANYของ Darwin จะทำให้ล้มเหลวหากองค์ประกอบใด ๆ ของ path เป็น symbolic link - หากสามารถฝัง symbolic link บางตัวไว้ใน docroot ได้ ก็น่าจะมีปัญหาอื่นอยู่ก่อนแล้ว แต่แฟลกนี้ก็ช่วยเพิ่มการป้องกันได้อีกชั้น
- ใน POSIX แฟลก
พฤติกรรมเฉพาะของ Apple
-
การจัดการ timeout และ
sigaction()- หากต้องการทำ request timeout ต้องใช้ system call
setitimer()หมายเลข83เพื่อให้ส่งSIGALRMหลังเวลาที่กำหนด - โดยปกติ
SIGALRMจะฆ่า child process ทันที แต่ ymawky ต้องส่ง408 Request Timeoutก่อน - จึงต้องใช้ system call
sigaction()หมายเลข46 - โครงสร้าง raw
sigactionของ Darwin เปิดเผยฟิลด์sa_tramp - ปกติ libc จะเป็นผู้ตั้งค่า
sa_trampเพื่อเก็บ stack และรีจิสเตอร์ เตรียมsigreturnแล้วค่อยกระโดดไปยัง handler - แต่ timeout handler ของ ymawky จะส่ง
408 Request Timeout, ปิดสิ่งที่จำเป็น แล้วจบ child process ไปเลย จึงไม่จำเป็นต้อง return กลับ - ดังนั้นจึงตั้ง trampoline slot ให้ชี้ไปยังโค้ดที่ตอบ timeout โดยตรง เพื่อข้ามทั้ง
sa_handlerและsigreturn
- หากต้องการทำ request timeout ต้องใช้ system call
-
proc_info()และการจำกัดจำนวน child process- Apple มี system call
proc_info()หมายเลข336ที่เอกสารไม่ชัดเจนนักสำหรับดึงข้อมูลโปรเซสที่กำลังรันและข้อมูลของ child - โดยทั่วไป system call นี้ถูกใช้ในเครื่องมืออย่าง
ps,lsof,top - ymawky ใช้
proc_info()เพื่อนับจำนวน child process ที่ยังทำงานอยู่ - เพราะสามารถตั้งค่าจำนวนการเชื่อมต่อสูงสุดได้ จึงต้องรู้ว่ามี child ที่ยังมีชีวิตอยู่กี่ตัว
proc_info()จะเขียนข้อมูล child process ลงในบัฟเฟอร์ และเพราะรู้ขนาดของแต่ละรายการอยู่แล้ว จึงคำนวณจำนวน child ได้จากจำนวนไบต์ที่เขียนกลับมา- หากจำนวน child เกิน
MAX_PROCSการเชื่อมต่อใหม่จะถูกปฏิเสธด้วย503 Service Unavailable
- Apple มี system call
บทสรุปและข้อมูลโปรเจกต์
- สำหรับ static web server ส่วนที่ยากกว่าเรื่องเปิด socket และ listen คือการ parse request และจัดการเงื่อนไขขอบทั้งหมด
- request, path, และ response ล้วนเป็นไบต์ทั้งหมด คำขอ range ต้องแม่นยำ และชื่อไฟล์ก็ต้อง escape ต่างกันตามตำแหน่งที่ถูกนำไปใช้
- assembly บังคับให้ต้องเขียนทุกอย่างเอง ตั้งแต่ request parsing, memory management, error handling, string conversion, timeout ไปจนถึง file safety
- ymawky ดูแลโดย imtomt
1 ความคิดเห็น
ความคิดเห็นจาก Lobste.rs
สุดยอดมาก เมื่อก่อนฉันเคยทำงานเชื่อมต่อกับบริษัทเล็ก ๆ ที่ทำอุปกรณ์อัจฉริยะอยู่แห่งหนึ่ง และวิศวกรเพียงคนเดียวของบริษัทนั้นรู้แค่ assembly language เท่านั้น
ตั้งแต่โค้ดควบคุมฮาร์ดแวร์, ระบบปฏิบัติการของเซิร์ฟเวอร์ ไปจนถึง JSON web API ที่เราใช้งาน ล้วนเขียนขึ้นเองด้วยแอสเซมบลีทั้งหมด
ครั้งหนึ่งเราเจอบั๊กที่ web API ส่งคืนข้อมูลของอุปกรณ์ผิดตัว แล้วสุดท้ายก็พบว่าเป็นข้อผิดพลาดแบบ off-by-one ในระบบจัดตารางของระบบปฏิบัติการ ทำให้ “ฐานข้อมูล” ส่งคืนแถวที่ผิดให้กับเว็บเซอร์วิส
เวลาพูดถึงคำอย่าง “การฆ่าตัวตาย” ช่วยใส่ คำเตือนเนื้อหา หน่อยเถอะ ถ้าจะให้ดีกว่านั้นก็คือไม่ต้องพูดถึงเลย
พอมาเห็นคอมเมนต์นี้ก็กลับไปหาอีกรอบแล้วก็ยังไม่เจอ ฉันพลาดอะไรไปหรือเปล่า?
พอเห็นคำว่า “เขียนทุกอย่างด้วยแอสเซมบลี” ก็ทำให้นึกถึง รายงานการสอบสวน Therac-25