Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

15KB

WSC-MVC — Decisions and Experiment Log

M0 — Environment inventory (2026-09-19, win2025test VM, 100.127.62.31)

Host: Windows Server 2025 Standard, build 10.0.26100, 64-bit, AMD Ryzen 5 1500X (4c). Verified via SSH (key-based, Administrator) + PowerShell 5.1.

  • IIS: installed (Web-Server) with Web-ASP, Web-CGI, Web-ISAPI-Ext, Web-ISAPI-Filter, management tools/console/scripting tools all Installed.
  • URL Rewrite module: present (%SystemRoot%\system32\inetsrv\rewrite.dll, registered as RewriteModule in global modules).
  • WebAdministration PowerShell module: available (v1.0.0.0).
  • WSC/COM runtime: scrobj.dll present in both System32 and SysWOW64.
  • regsvr32.exe, cscript.exe, wscript.exe: present at standard C:\WINDOWS\system32 paths.
  • Existing app pools are all 64-bit (enable32BitAppOnWin64 = False); no 32-bit requirement observed. WSC-MVC will target 64-bit and re-verify if this changes.
  • Existing sites: Default Web Site (*:80), AspClassicUnifiedFramework (*:8080, unrelated pre-existing project). No port conflict for a new dedicated site on *:8090.
  • Disk: C: only (106 GB, ~80 GB free). No D:/E:/F: volumes despite drive letters being visible (0 bytes each — not usable). Project deployed under C:\Projects\wsc-mvc.
  • Firewall: World Wide Web Services (HTTP Traffic-In) inbound rule enabled.
  • HTTP reachability: VM only reachable over the private Tailscale tailnet (100.127.62.31); no public exposure.

No blocking gaps found for M0/M1. Proceeding to M1.

Open question #1 — Can WSC receive ASP intrinsic objects across the IIS/COM boundary?

Decision: not attempted for M1. Per SPEC and AGENTS.md hard rules, business logic must never reference ASP Request/Response/Server/Session anyway, so this is avoided by design rather than tested as a compatibility question. Application.Run and HomeController.Hello use a primitive-only, ByRef-out contract: all parameters are strings (pathInfo in; statusLine, contentType, body out via VBScript's default ByRef parameter passing — no ByVal/ByRef keywords are available in WSC <public> XML, so behavior follows the Sub/Function signature in the script block). Default.asp owns all ASP host object access and is the only file that touches Request/Response/Server.

This resolves the open question pragmatically: no experiment needed because the architecture never crosses that boundary. Revisit only if a future milestone needs to pass richer data (e.g. multi-value POST bodies) — prefer additional scalar out-params or a Scripting.Dictionary (a standard COM component, not an ASP intrinsic) over passing ASP objects into a WSC.

Registration tooling

regsvr32.exe /s "<path>.wsc" registers a Windows Script Component directly (scrobj.dll is the underlying handler associated with the .wsc extension). Verified working for both Application.wsc and HomeController.wsc on this host — see docs/TEST-RESULTS.md. Both scripts require elevation (registry write); the deployment/registration step is intentionally separate from the request-handling pipeline per SPEC §10 and AGENTS.md.

Finding: regsvr32 /s /u component.wsc does not actually unregister. Verified experimentally on this host (Windows Server 2025, build 26100): after regsvr32 /s /u on both components, regsvr32 reports exit code 0, but HKLM:\SOFTWARE\Classes\<ProgID> and HKLM:\SOFTWARE\Classes\CLSID\<CLSID> are both still present and functional (confirmed via direct registry inspection and a follow-up CreateObject that still succeeded). This reproduced identically via Start-Process -Wait and via a direct cmd-level regsvr32 /s /u call with %ERRORLEVEL% checked. This is a real scrobj.dll/WSC limitation, not a script bug — do not trust regsvr32 /u's exit code as evidence of removal for .wsc files on this host.

Fix implemented: tools/Unregister-Components.ps1 still calls regsvr32 /s /u first (harmless best-effort), then explicitly checks whether HKLM:\SOFTWARE\Classes\<ProgID> still resolves to this project's known CLSID, and if so removes exactly that ProgID key and exactly that CLSID key (recursive), nothing else. It refuses (throws) instead of deleting if the registered CLSID under that ProgID doesn't match what this project expects, to guarantee it can never remove a different component's registration that happens to reuse a ProgID string. Full reversibility cycle (register → verify via HTTP+WSH → unregister → verify HTTP 500 + WSH CreateObject failure → re-register → verify recovery) was run end-to-end and passed; see docs/TEST-RESULTS.md.

Finding: IIS httpErrors substitutes its own error page for remote clients by default

With the out-of-the-box system.webServer/httpErrors setting (errorMode="DetailedLocalOnly"), a request from a remote client (e.g. over the tailnet, not literally localhost on the IIS box) that hits a 500 gets IIS's own generic friendly-error HTML page, not whatever Default.asp wrote to the response body — even though Default.asp's own On Error Resume Next / Err.Number handling ran correctly and wrote its intended "Internal Server Error" / text/plain body. Verified by comparing a curl from the Linux host (tailnet IP) against a curl run locally on the VM (http://localhost:8090/hello) with components deliberately unregistered: the local request returned our own exact Content-Type: text/plain; charset=utf-8 / Internal Server Error body; the remote request returned IIS's generic HTML page. Both are HTTP 500 and both satisfy the SPEC §14/§10 requirement (safe failure, no path or exception text leaked to any client) — this is IIS being conservative for non-local requests, not a defect in our error handling. Documented here so a future milestone doesn't mistake this for Default.asp's error branch not firing.

Diagnostic note: system.webServer/asp is locked (overrideModeDefault="Deny") at the machine level by default, so it cannot be overridden from a site's own web.config. Toggling scriptErrorSentToBrowser for diagnosis requires appcmd unlock config /section:system.webServer/asp first; remember to appcmd lock config /section:system.webServer/asp again afterward and to manually strip any <asp .../> element that Set-WebConfigurationProperty writes into the site's web.config before re-locking, or the site's config becomes invalid (locked section referenced from a location that has an explicit override) and every request 500s until the stray element is removed. This happened once during M1 testing on this host and was fixed by editing web.config directly; the repository's web.config was never affected (this only happened to the deployed copy on the VM).

Static/source protection approach

Used IIS requestFiltering/hiddenSegments (blocks any URL path containing Framework, Controllers, tests, tools, docs as a path segment, anywhere in the tree) plus requestFiltering/fileExtensions denylist for .wsc, .vbs, .ps1, .md. This keeps Default.asp at the project root (matching the SPEC §5 target tree) while denying direct access to source/config/test files, rather than moving Default.asp into a separate public/ webroot. Verified with tests/Test-Http.ps1 (direct .wsc request must return 404).

Site/port allocation

New dedicated site WscMvc, app pool WscMvc (64-bit, no managed code), binding *:8090, physical path C:\Projects\wsc-mvc. Chosen to avoid the existing *:80 and *:8080 bindings already in use on this VM.

M2 — Lifecycle, central error handling, correlation-safe diagnostics (2026-09-19)

Added Framework/RequestContext.wsc (WscMvc.RequestContext), a per-request, primitive-only data holder (path, HTTP method, a correlation id, a log directory, elapsed-time tracking). Default.asp creates and Initializes one per request and passes it into Application.Run(ctx, ...) in place of the raw pathInfo string from M1. Application.wsc centralizes both the expected-vs-unexpected outcome decision (404 is expected control flow; a CreateObject/method-call failure is 500) and best-effort logging of every outcome (logs/app.log: timestamp, correlation id, method, path, status line, elapsed ms) in one place (LogOutcome), per SPEC §10/§13's M2 requirement. logs/ was added to web.config's hiddenSegments so it is never HTTP-reachable.

Three real defects were found and fixed while building this, all confirmed experimentally rather than assumed:

1. Property Get/Let/Set cannot be used at the top level of a WSC <script> block. First draft of RequestContext.wsc exposed Path/HttpMethod/CorrelationId/LogDir via Property Get procedures declared via <property><get/></property> in the <public> block. Registration failed with regsvr32 exit code 5 (DllRegisterServer failed). Isolated with GetObject("script:<path>.wsc") from cscript, which surfaced the real error: VBScript error 1048, "Must be defined inside a Class". VBScript only allows Property Get/Let/Set inside a Class...End Class block; a WSC's top-level script scope is not a class, regardless of what the WSC <public> XML declares. Fixed by exposing plain parameterless Functions (Path(), HttpMethod(), etc.) via <method> instead of <property> — the same pattern already proven working in Application.wsc/HomeController.wsc. Late-bound VBScript call sites (ctx.Path, no parens) work identically whether the target is a method or a property, so no caller code needed to change.

2. FormatNumber() leaks a locale thousands-separator into generated ids. The first correlation-id implementation built a value from FormatNumber(Timer, 4); on this host's locale that produces a comma-grouped string (e.g. 32,188.0400), so a correlation id came out containing a literal comma (32,1880400-714275) — harmless today but a latent bug for any future comma-delimited log/CSV consumer. Confirmed via a standalone GetObject-based probe before it ever reached a real component. Fixed by building the numeric parts with Fix()/integer arithmetic instead of FormatNumber().

3. Randomize + Rnd() collide when called twice inside the same clock tick. After fixing (2), a WSH test creating two RequestContext instances back-to-back got the same correlation id both times. Root cause, confirmed experimentally: Randomize with no argument reseeds VBScript's RNG from the system timer; two calls within the same timer resolution window reseed to an identical state, so the immediately-following Rnd() returns the same first value both times. This is a genuine hazard for any correlation/nonce-style id generated this way in a tight loop (exactly the shape of “two requests arriving close together,” which is the whole point of a correlation id). Fixed by dropping Randomize/Rnd() entirely in favor of Scripting.FileSystemObject.GetTempName(), confirmed experimentally to return a distinct value on every call in a tight loop with no dependency on Randomize state.

4. Best-effort append-logging to one shared text file loses lines under concurrency, and a plain retry-the-open loop does not fix it. With 8 genuinely concurrent /hello requests (confirmed genuinely concurrent via a control experiment: unique-per-request marker files with no shared-file contention all succeeded independently), a naive OpenTextFile(path, ForAppending, Create:=True) on every request lost roughly half its lines — not because the open call fails under contention (it mostly succeeds; OpenTextFile isn't exclusive), but because unsynchronized concurrent appends is a lost-update race. Retrying only the open call made this worse, not better (measured: down to 1 surviving line of 8), because it does nothing to stop the underlying race. Fixed with a manual mutex: FileSystemObject.CreateTextFile(lockPath, OverwriteExisting:=False) atomically fails with Err.Number=58 "File already exists" when another request already holds the lock file — confirmed correct/exclusive with a dedicated diagnostic harness, not assumed. The lock genuinely serializes the critical section (open app.log, write, close), but making every concurrent request eventually win requires a large retry budget: measured 300 retries (~330-360ms of blocking) needed for 8-way concurrency before all callers reliably see the lock come free. That much added latency on every request is a bad tradeoff for a best-effort diagnostic write, so the shipped retry budget is deliberately short (10 attempts, ~a few ms). Accepted, documented consequence: under moderate-to-heavy concurrent load, some app.log lines will legitimately be dropped; this never affects the HTTP response, which is correct and on-time in 100% of observed cases regardless of logging outcome. Revisit with a real logging mechanism (e.g. per-request-unique files aggregated out of band, or a proper OS-level named mutex via a small helper) in M6 if complete log coverage under load becomes an actual requirement — do not spend more effort on this in M2, it is explicitly a “safe diagnostic information” nice-to-have per SPEC §10, not a correctness requirement.

Investigated and resolved: an inherited IUSR:(F) ACE appeared under logs/

While diagnosing the concurrency finding above, icacls C:\Projects\wsc-mvc\logs showed an inherited NT AUTHORITY\IUSR:(I)(F) (Full Control) entry that wasn't present as an explicit ACE on C:\Projects\wsc-mvc or C:\Projects themselves. Initially flagged as a possible SPEC §10 “no broad filesystem write privileges” violation (anonymous web identity with Full Control beyond what it needs). Investigated properly rather than left as a guess: a recursive icacls C:\Projects\wsc-mvc /T scan shows the IUSR:(F) ACE present only on logs/ and files/folders created underneath it during testing (app.log, diagnostic marker files, etc.) — confirmed absent on Framework/, Controllers/, Default.asp, web.config, tools/, tests/. Both C:\Projects and C:\Projects\wsc-mvc carry an inherited CREATOR OWNER:(I)(OI)(CI)(IO)(F) ACE (the (IO) = inherit-only flag), which is standard NTFS behavior: it doesn't grant CREATOR OWNER anything on the folder itself, but materializes into a concrete Full Control ACE for whichever security principal actually creates each new child file/folder underneath. Since IUSR (impersonated by classic ASP for anonymous requests, per this site's anonymousAuthentication config) was the one creating files under logs/ during testing, it received Full Control on exactly those files it created there — nowhere else. Resolved, not a defect: this is expected NTFS ownership semantics, correctly scoped to only the dynamically-created log tree (which is already denied over HTTP via hiddenSegments), not a broad grant across the source tree. No remediation needed; no M6 follow-up required for this specific concern.

Powered by TurnKey Linux.