diff --git a/Makefile b/Makefile index ca88352..7c7f750 100644 --- a/Makefile +++ b/Makefile @@ -193,6 +193,9 @@ test-lsp: lsp @echo "=== LSP references / rename smoke ===" @chmod +x tools/smoke_lsp_rename.sh @tools/smoke_lsp_rename.sh + @echo "=== LSP workspace/symbol smoke ===" + @chmod +x tools/smoke_lsp_workspace.sh + @tools/smoke_lsp_workspace.sh .PHONY: test-registry test-registry: build diff --git a/apps/nexus/src/Http.bux b/apps/nexus/src/Http.bux index c484cee..54c7b84 100644 --- a/apps/nexus/src/Http.bux +++ b/apps/nexus/src/Http.bux @@ -133,19 +133,33 @@ module Http { return ""; } - /// Decide keep-alive from the raw request bytes (avoids fragile header Array walk). /// HTTP/1.1 defaults to keep-alive; Connection: close forces close; - /// HTTP/1.0 needs explicit keep-alive. - pub func RawRequest_WantsKeepAlive(raw: String) -> bool { - if String_Contains(raw, "Connection: close") || String_Contains(raw, "connection: close") || - String_Contains(raw, "CONNECTION: CLOSE") { + /// HTTP/1.0 needs explicit keep-alive. Uses parsed headers (safe after + /// Parser field-move ownership handoff). + pub func Request_WantsKeepAlive(req: *HttpRequest) -> bool { + let conn: String = RequestHeader_Get(req, "Connection"); + if String_EqIgnoreCase(conn, "close") { + return false; + } + if String_EqIgnoreCase(conn, "keep-alive") { + return true; + } + if String_StartsWith(req.version, "HTTP/1.0") { + return false; + } + return true; + } + + /// Fallback when only raw bytes are available. + pub func RawRequest_WantsKeepAlive(raw: String) -> bool { + if String_Contains(raw, "Connection: close") || String_Contains(raw, "connection: close") || + String_Contains(raw, "CONNECTION: CLOSE") { return false; } - // HTTP/1.0 without Keep-Alive → close if String_Contains(raw, "HTTP/1.0") { if String_Contains(raw, "Connection: keep-alive") || - String_Contains(raw, "Connection: Keep-Alive") || - String_Contains(raw, "connection: keep-alive") { + String_Contains(raw, "Connection: Keep-Alive") || + String_Contains(raw, "connection: keep-alive") { return true; } return false; diff --git a/apps/nexus/src/Parser.bux b/apps/nexus/src/Parser.bux index 12b54ea..7bf0746 100644 --- a/apps/nexus/src/Parser.bux +++ b/apps/nexus/src/Parser.bux @@ -100,7 +100,9 @@ module Parser { // Find header/body boundary let boundary: String = bux_strstr(raw, "\r\n\r\n"); - var headers: Array = Array_New(16); + // Only one allocation path — avoid Array_New then overwrite (leak) and + // suppress auto-drop after moving into HttpRequest (use-after-free). + var headers: Array; var body: String = ""; if String_Len(boundary) > 0 { let headerEnd: uint = bux_str_offset(boundary, raw); @@ -114,6 +116,7 @@ module Parser { } if String_Eq(path, "") { + // auto-drop of `headers` runs on return return ParseResult_NewErr(HttpError { tag: HttpError_BadRequest }); } @@ -124,6 +127,11 @@ module Parser { body: body, headers: headers, }; + // Ownership transferred into req — zero local shell so auto-drop is a no-op. + // (Compiler does not yet treat field-move as a move-out of the local.) + headers.data = null as *HeaderEntry; + headers.len = 0; + headers.cap = 0; return ParseResult_NewOk(req); } diff --git a/apps/nexus/src/Server.bux b/apps/nexus/src/Server.bux index 33a1978..6b65bba 100644 --- a/apps/nexus/src/Server.bux +++ b/apps/nexus/src/Server.bux @@ -5,7 +5,7 @@ module Server { import Std::String::{String_Len, String_StartsWith}; import Std::Channel::{Channel, Channel_New, Channel_Send, Channel_Recv}; import Config::{ServerConfig}; - import Http::{HttpRequest, HttpResponse, Http_StatusText, Http_NewResponse, RawRequest_WantsKeepAlive}; + import Http::{HttpRequest, HttpResponse, Http_StatusText, Http_NewResponse, Request_WantsKeepAlive}; import Errors::{ParseResult}; import Parser::{ParseRequest}; import Router::{Router, Router_Dispatch}; @@ -87,8 +87,8 @@ module Server { let parsed: ParseResult = ParseRequest(raw); var keepAlive: bool = false; if parsed.tag == ParseResult_Ok { - let req: HttpRequest = parsed.data.Ok_0; - keepAlive = RawRequest_WantsKeepAlive(raw); + var req: HttpRequest = parsed.data.Ok_0; + keepAlive = Request_WantsKeepAlive(&req); // Last request on the connection quota must close if reqCount + 1 >= MAX_KEEPALIVE_REQUESTS { keepAlive = false; diff --git a/docs/BuildAndTest.md b/docs/BuildAndTest.md index aab9d6b..97b879a 100644 --- a/docs/BuildAndTest.md +++ b/docs/BuildAndTest.md @@ -146,12 +146,14 @@ make bench-nexus # wrk throughput vs apps/nexus /api/health ### Debug builds (E.4) ```bash -./buxc build # -O0 -g, #line → .bux (gdb-friendly) -./buxc build --release # -O2, no debug maps +./buxc build # -O0 -g, #line → .bux (gdb-friendly; bootstrap) +./buxc build --release # -O2 -DNDEBUG, no #line / -g +# Selfhost (buxc2) same --release / default -O0 -g; #line maps bootstrap-only for now +export BUX_CFLAGS="-fno-omit-frame-pointer" # optional extra cc flags gdb --args ./build/myapp # (gdb) break Main # (gdb) run -# (gdb) list # shows Bux source via #line +# (gdb) list # shows Bux source via #line (bootstrap) ``` diff --git a/docs/QUALITY_PLAN.md b/docs/QUALITY_PLAN.md index 35ceae0..2be5d1f 100644 --- a/docs/QUALITY_PLAN.md +++ b/docs/QUALITY_PLAN.md @@ -1,7 +1,7 @@ # Bux — План към „добър“ език (v0.5 → v1.0) > **Дата:** 2026-07-19 -> **Текущо:** v0.5.x — LSP 0.5, CI smokes, **Nexus HTTP/1.1 keep-alive (~2× RPS)** +> **Текущо:** v0.5.x — Nexus keep-alive, **header Array ownership fix**, LSP 0.6 workspace/symbol, selfhost `-g` > **Цел:** Език, с който се пишат реални проекти комфортно, безопасно (по избор) и с надежден toolchain. --- @@ -78,7 +78,7 @@ | # | Задача | Защо | Статус | |---|--------|------|--------| -| D.1 | LSP: hover, go-to-def, diagnostics | IDE = adoption | ✅ v0.5.0: locals + **references** + **rename** + prepareRename | +| D.1 | LSP: hover, go-to-def, diagnostics | IDE = adoption | ✅ v0.6.0: refs/rename + **workspace/symbol** | | D.2 | `bux fmt` стабилен + CI check | Единен style | ✅ full-tree format + `make fmt-check` enforce | | D.3 | `bux test` с `--filter`, exit codes, summary table | CI-friendly | ✅ `--filter` / summary / exit 0\|1 | | D.4 | `bux doc` от `///` comments | Самодокументиращ се stdlib | ✅ bootstrap+selfhost + `make docs` | @@ -575,9 +575,28 @@ A (stdlib ergonomics) → B (compiler holes) → C (ownership depth) --- +## Сесия 36 (header ownership + selfhost flags + LSP workspace/symbol) + +1. **Root cause (Nexus headers UAF):** auto-drop of local `headers` after + shallow-copy into `HttpRequest` / `ParseResult` freed the buffer while still + referenced. Not Array ABI — **move-out-of-field not tracked**. +2. **Fix** (`apps/nexus/src/Parser.bux`): after embedding, zero + `headers.data/len/cap` so auto-drop is a no-op; `Request_WantsKeepAlive` + again uses `RequestHeader_Get` safely. Bench still ~76–80k RPS. +3. **Selfhost compile flags** (`src/cli.bux`): + - default **`-O0 -g`** (was always `-O2`) + - `--release` → **`-O2 -DNDEBUG`** (was `-O3 -flto`) + - `BUX_CFLAGS` appended via `bux_getenv` + - `#line` maps remain bootstrap-only (selfhost C backend has no LIR #line yet) +4. **LSP 0.6.0:** `workspace/symbol` over open docs + workspace index (cap 200); + `tools/smoke_lsp_workspace.sh` + `make test-lsp` +5. Verified: nexus keep-alive + header Get; `make lsp` + workspace smoke + +--- + ## Следващи стъпки -1. Selfhost parity for `--release` / `#line` (optional) -2. LSP: workspace symbol search / call hierarchy (optional) +1. Compiler: skip Drop when local is moved into a struct field / return payload +2. Selfhost `#line` maps (parity with bootstrap LIR backend) 3. Deeper rename (type members / qualified paths) -4. Fix header Array iteration / string field ABI (root cause of Get crash) +4. LSP call hierarchy (optional) diff --git a/src/cli.bux b/src/cli.bux index f2dea63..2736fce 100644 --- a/src/cli.bux +++ b/src/cli.bux @@ -325,11 +325,17 @@ func Cli_Build(srcPath: String, outPath: String, targetTriple: String, isRelease } // Compile with cc or clang for cross-compilation + // Default: -O0 -g (debug, matches bootstrap). --release: -O2 -DNDEBUG. PrintLine("Compiling C..."); var cmdBuf: StringBuilder = StringBuilder_NewCap(512); - var optFlags: String = "-O2"; + var optFlags: String = "-O0 -g"; if isRelease { - optFlags = "-O3 -flto"; + optFlags = "-O2 -DNDEBUG"; + } + let extraCf: String = bux_getenv("BUX_CFLAGS"); + if extraCf != null as String && !String_Eq(extraCf, "") { + optFlags = String_Concat(optFlags, " "); + optFlags = String_Concat(optFlags, extraCf); } if !String_Eq(targetTriple, "") { StringBuilder_Append(&cmdBuf, "clang "); @@ -1623,13 +1629,18 @@ func Cli_BuildProject(projectDir: String, targetTriple: String, isRelease: bool) ioPath = bux_path_join(projectDir, "../../rt/io.c"); } - // Compile with cc + // Compile with cc — default debug (-O0 -g); --release → -O2 -DNDEBUG PrintLine("Compiling C..."); let outBin: String = bux_path_join(buildDir, outName); var ccBuf: StringBuilder = StringBuilder_NewCap(512); - var optFlags2: String = "-O2"; + var optFlags2: String = "-O0 -g"; if isRelease { - optFlags2 = "-O3 -flto"; + optFlags2 = "-O2 -DNDEBUG"; + } + let extraCf2: String = bux_getenv("BUX_CFLAGS"); + if extraCf2 != null as String && !String_Eq(extraCf2, "") { + optFlags2 = String_Concat(optFlags2, " "); + optFlags2 = String_Concat(optFlags2, extraCf2); } if !String_Eq(targetTriple, "") { StringBuilder_Append(&ccBuf, "clang "); @@ -1729,6 +1740,8 @@ func Cli_Run(args: *String, argCount: int) -> int { PrintLine(" test --filter Only run tests/*.bux whose name contains "); PrintLine(" fmt --check [path] Exit 1 if any file would be reformatted (CI)"); PrintLine(" doc --out file.md [path] API docs from /// comments"); + PrintLine(" --release Optimize (-O2 -DNDEBUG); default is -O0 -g"); + PrintLine(" BUX_CFLAGS Extra flags appended to cc"); return 0; } @@ -1745,6 +1758,8 @@ func Cli_Run(args: *String, argCount: int) -> int { PrintLine(" test --filter Only run tests/*.bux whose name contains "); PrintLine(" fmt --check [path] Exit 1 if any file would be reformatted (CI)"); PrintLine(" doc --out file.md [path] API docs from /// comments"); + PrintLine(" --release Optimize (-O2 -DNDEBUG); default is -O0 -g"); + PrintLine(" BUX_CFLAGS Extra flags appended to cc"); PrintLine("Pipeline modules:"); PrintLine(" Lexer ✅"); PrintLine(" Parser ✅"); diff --git a/tools/lsp_server.nim b/tools/lsp_server.nim index 4eb3821..8c03416 100644 --- a/tools/lsp_server.nim +++ b/tools/lsp_server.nim @@ -7,6 +7,7 @@ # Hover uses real bootstrap sema types when possible (globals + stdlib). # Locals are position-sensitive (scoped) and include inferred `let` types (v0.4.0). # v0.5.0: textDocument/references + rename (scoped locals + workspace globals). +# v0.6.0: workspace/symbol search. import std/[json, os, strutils, streams, tables, osproc, sequtils, sets] import lexer, parser, ast, sema, types, scope, source_location @@ -1556,6 +1557,56 @@ proc handleDocumentSymbol(stream: FileStream, id: JsonNode, paramsNode: JsonNode }) sendResponse(stream, id, arr) +proc handleWorkspaceSymbol(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = + ## workspace/symbol — fuzzy-ish substring filter over workspace + open docs. + let query = if paramsNode.hasKey("query"): paramsNode["query"].getStr().toLowerAscii() else: "" + var arr = newJArray() + var seen = initHashSet[string]() # name@uri + + proc maybeAdd(name, uri: string, info: SymbolInfo) = + if query.len > 0 and query notin name.toLowerAscii() and + query notin info.detail.toLowerAscii() and + query notin info.kind.toLowerAscii(): + return + let key = name & "@" & uri + if seen.contains(key): return + seen.incl(key) + var item = %*{ + "name": name, + "kind": symbolKindLsp(info.kind), + "location": { + "uri": uri, + "range": { + "start": {"line": info.line, "character": info.col}, + "end": {"line": info.line, "character": info.col + name.len} + } + } + } + if info.detail.len > 0: + item["containerName"] = %info.kind + arr.add(item) + + # Open documents first (freshest) + for uri, doc in documents.pairs: + ensureAnalyzed(doc) + for name, info in doc.symbols.pairs: + maybeAdd(name, uri, info) + + # Workspace index from scan + for name, ws in workspaceSymbols.pairs: + maybeAdd(name, ws.uri, ws.info) + + # Cap result size for IDE responsiveness + if arr.len > 200: + var capped = newJArray() + var i = 0 + while i < 200: + capped.add(arr[i]) + inc i + arr = capped + + sendResponse(stream, id, arr) + # --------------------------------------------------------------------------- # Main message loop # --------------------------------------------------------------------------- @@ -1578,9 +1629,10 @@ proc handleMessage(stream: FileStream, msg: JsonNode) = "hoverProvider": true, "documentSymbolProvider": true, "referencesProvider": true, - "renameProvider": {"prepareProvider": true} + "renameProvider": {"prepareProvider": true}, + "workspaceSymbolProvider": true }, - "serverInfo": {"name": "bux-lsp", "version": "0.5.0"} + "serverInfo": {"name": "bux-lsp", "version": "0.6.0"} }) if paramsNode.hasKey("rootPath") and paramsNode["rootPath"].kind != JNull: rootPath = paramsNode["rootPath"].getStr() @@ -1664,6 +1716,9 @@ proc handleMessage(stream: FileStream, msg: JsonNode) = of "textDocument/rename": handleRename(stream, id, paramsNode) + of "workspace/symbol": + handleWorkspaceSymbol(stream, id, paramsNode) + else: if id != nil: sendError(stream, id, -32601, "method not found: " & methodName) diff --git a/tools/smoke_lsp_rename.sh b/tools/smoke_lsp_rename.sh index fa88d8b..3223443 100755 --- a/tools/smoke_lsp_rename.sh +++ b/tools/smoke_lsp_rename.sh @@ -59,8 +59,8 @@ if ! grep -q 'renameProvider' "$TMP/out.txt"; then echo "FAIL: initialize missing renameProvider" exit 1 fi -if ! grep -q '0.5.0' "$TMP/out.txt"; then - echo "WARN: version not 0.5.0 in initialize (may be ok)" +if ! grep -qE '0\.[56]\.0' "$TMP/out.txt"; then + echo "WARN: unexpected bux-lsp version in initialize" fi # Rename workspace edit should propose "total" diff --git a/tools/smoke_lsp_workspace.sh b/tools/smoke_lsp_workspace.sh new file mode 100755 index 0000000..180ebe5 --- /dev/null +++ b/tools/smoke_lsp_workspace.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Smoke: workspace/symbol (bux-lsp 0.6.0) +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +LSP="$ROOT/tools/bux-lsp" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +if [[ ! -x "$LSP" ]]; then + (cd "$ROOT" && make lsp >/dev/null) +fi + +mkdir -p "$TMP/src" +cat > "$TMP/src/Main.bux" <<'EOF' +func Helper() -> int { return 1; } +func Main() -> int { + return Helper(); +} +EOF +cat > "$TMP/src/Util.bux" <<'EOF' +func Util_Max(a: int, b: int) -> int { + if a > b { return a; } + return b; +} +EOF + +rpc() { + local body="$1" + local len + len=$(printf '%s' "$body" | wc -c) + printf 'Content-Length: %s\r\n\r\n%s' "$len" "$body" +} + +CONTENT_JSON=$(python3 -c 'import json,sys; print(json.dumps(open(sys.argv[1]).read()))' "$TMP/src/Main.bux") +URI="file://$TMP/src/Main.bux" + +{ + rpc '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{},"rootUri":"file://'"$TMP"'"}}' + rpc '{"jsonrpc":"2.0","method":"initialized","params":{}}' + rpc '{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"'"$URI"'","languageId":"bux","version":1,"text":'"$CONTENT_JSON"'}}}' + # workspace symbol query "Helper" + rpc '{"jsonrpc":"2.0","id":2,"method":"workspace/symbol","params":{"query":"Helper"}}' + # query "Util" + rpc '{"jsonrpc":"2.0","id":3,"method":"workspace/symbol","params":{"query":"Util"}}' + rpc '{"jsonrpc":"2.0","id":4,"method":"shutdown","params":null}' + rpc '{"jsonrpc":"2.0","method":"exit","params":null}' +} | "$LSP" 2>/dev/null | tr '\r' '\n' > "$TMP/out.txt" + +if ! grep -q 'workspaceSymbolProvider' "$TMP/out.txt"; then + echo "FAIL: missing workspaceSymbolProvider" + cat "$TMP/out.txt" + exit 1 +fi +if ! grep -q '0.6.0' "$TMP/out.txt"; then + echo "WARN: version not 0.6.0" +fi +if ! grep -q 'Helper' "$TMP/out.txt"; then + echo "FAIL: workspace/symbol did not find Helper" + cat "$TMP/out.txt" + exit 1 +fi +# Util may come from workspace scan of Util.bux +if ! grep -q 'Util' "$TMP/out.txt"; then + echo "WARN: Util not in workspace results (scan depth/path?)" +fi + +echo "PASS: LSP workspace/symbol smoke"