fix: Array move ownership, selfhost -g flags, LSP workspace/symbol

Session 36: prevent UAF when Array is moved into a struct field (Nexus
headers), align selfhost cc flags with bootstrap debug/release, and add
workspace/symbol search to bux-lsp 0.6.

- Parser: zero headers after embedding so auto-drop is a no-op
- Request_WantsKeepAlive uses RequestHeader_Get again safely
- Selfhost: default -O0 -g; --release -O2 -DNDEBUG; BUX_CFLAGS
- LSP: workspace/symbol + smoke; version 0.6.0
This commit is contained in:
2026-07-19 22:59:02 +03:00
parent e30d8a5eb9
commit adc5c743a6
10 changed files with 212 additions and 29 deletions
+3
View File
@@ -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
+17 -3
View File
@@ -133,15 +133,29 @@ 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.
/// 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") ||
+9 -1
View File
@@ -100,7 +100,9 @@ module Parser {
// Find header/body boundary
let boundary: String = bux_strstr(raw, "\r\n\r\n");
var headers: Array<HeaderEntry> = Array_New<HeaderEntry>(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<HeaderEntry>;
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);
}
+3 -3
View File
@@ -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;
+5 -3
View File
@@ -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)
```
+24 -5
View File
@@ -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 ~7680k 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)
+20 -5
View File
@@ -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 <name> Only run tests/*.bux whose name contains <name>");
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 <name> Only run tests/*.bux whose name contains <name>");
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 ✅");
+57 -2
View File
@@ -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)
+2 -2
View File
@@ -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"
+67
View File
@@ -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"