Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/editors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
- run: npx --no-install vsce package --out loaddensity.vsix
- name: The package carries the schema
run: npx --no-install vsce ls | grep -Fx "schemas/loaddensity-action-schema.json"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: loaddensity-vscode
path: editors/vscode/loaddensity.vsix
Expand Down Expand Up @@ -78,15 +78,15 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
- uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
distribution: temurin
java-version: "17"
- uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
gradle-version: "8.10.2"
- run: gradle buildPlugin --no-daemon
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: loaddensity-jetbrains
path: editors/jetbrains/build/distributions/*.zip
4 changes: 2 additions & 2 deletions dev.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ content-type = "text/markdown"
find = { namespaces = false, include = ["je_load_density", "je_load_density.*"] }

[project.optional-dependencies]
gui = ["PySide6==6.11.2", "qt-material"]
gui = ["PySide6==6.12.0", "qt-material"]
websocket = ["websocket-client>=1.6"]
grpc = ["grpcio>=1.60", "protobuf>=4.25"]
mqtt = ["paho-mqtt>=2.0"]
Expand Down Expand Up @@ -109,7 +109,7 @@ cloud = [
"azure-mgmt-containerinstance>=10.1",
]
all = [
"PySide6==6.11.2", "qt-material",
"PySide6==6.12.0", "qt-material",
"websocket-client>=1.6",
"grpcio>=1.60", "protobuf>=4.25",
"paho-mqtt>=2.0",
Expand Down
17 changes: 17 additions & 0 deletions docs/updates/2026-10.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,3 +366,20 @@ Query existing PyPI releases when choosing the stable patch version, reusing the
## U-20261011-03 · 2026-10-11 · Include PyModbus serial import dependency · #done #ci

Request pymodbus[serial] in both modbus and all extras for both release channels, preventing PyModbus 3.15 client imports from failing with an undefined pyserial name. Regenerate uv.lock, including synchronization with the existing etcd3gw metadata. The exact PyModbus 3.15 import, request encoding and client construction pass in an isolated environment; 62 parity, matrix, release-helper and workflow tests pass; uv lock --check passes.

## U-20261022-05 · 2026-10-22 · Bound Windows socket shutdown and avoid subprocess pipe hangs · #bugfix #deps #ci

- PR #127: passed the Node DOM fixture through owned files so gevent-patched subprocess communication cannot hang on Windows. Kept the existing timeout and DOM/security assertions.
- Bounded the listening socket accept after stale readiness events: the cooperative server can observe shutdown instead of waiting forever for another client. Accepted connections keep their existing receive timeout policy.
- Validation: dashboard UI and socket child-process tests.

## U-20261022-01 · 2026-10-22 · Keep dependency metadata aligned · #bugfix #deps #ci

- Updated the matching package channel and current installation references for PR #128.
- Validation: test/test_dev_toml_parity.py.

## U-20261022-03 · 2026-10-22 · Bound Windows socket shutdown and avoid subprocess pipe hangs · #bugfix #deps #ci

- PR #131: passed the Node DOM fixture through owned files so gevent-patched subprocess communication cannot hang on Windows. Kept the existing timeout and DOM/security assertions.
- Bounded the listening socket accept after stale readiness events: the cooperative server can observe shutdown instead of waiting forever for another client. Accepted connections keep their existing receive timeout policy.
- Validation: dashboard UI and socket child-process tests.
3 changes: 3 additions & 0 deletions docs/updates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here

| ID | Date | Title | Tags | Batch |
|---|---|---|---|---|
| U-20261022-03 | 2026-10-22 | Bound Windows socket shutdown and avoid subprocess pipe hangs | #bugfix #deps #ci | [2026-10](2026-10.md) |
| U-20261022-01 | 2026-10-22 | Keep dependency metadata aligned | #bugfix #deps #ci | [2026-10](2026-10.md) |
| U-20261022-05 | 2026-10-22 | Bound Windows socket shutdown and avoid subprocess pipe hangs | #bugfix #deps #ci | [2026-10](2026-10.md) |
| U-20261011-03 | 2026-10-11 | Include PyModbus serial import dependency | #done #ci | [2026-10](2026-10.md) |
| U-20261011-02 | 2026-10-11 | Avoid duplicate stable PyPI versions | #done #ci | [2026-10](2026-10.md) |
| U-20261011-01 | 2026-10-11 | Clarify reviewed socket server analysis findings | #done #ci | [2026-10](2026-10.md) |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@
)


class _ShutdownAwareTCPServer(ActionTCPServer):
"""Bound an accept after a stale readiness event so shutdown can finish."""

def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.socket.settimeout(0.5)


def _print_info(message: str) -> None:
print(message, flush=True)

Expand Down Expand Up @@ -87,7 +95,9 @@ def start_load_density_socket_server(
if token is None:
token = os.environ.get(TOKEN_ENVIRONMENT_VARIABLE)
settings = socket_server_settings(framed=framed, token=token, certfile=certfile, keyfile=keyfile)
server = start_action_socket_server(host, port, settings, EnvelopeTokenRequestHandler)
server = start_action_socket_server(
host, port, settings, EnvelopeTokenRequestHandler, server_class=_ShutdownAwareTCPServer,
)
print(f"Server started on {host}:{port}", flush=True)
server.close_event.wait()
server.shutdown()
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ content-type = "text/markdown"
find = { namespaces = false, include = ["je_load_density", "je_load_density.*"] }

[project.optional-dependencies]
gui = ["PySide6==6.11.2", "qt-material"]
gui = ["PySide6==6.12.0", "qt-material"]
websocket = ["websocket-client>=1.6"]
grpc = ["grpcio>=1.60", "protobuf>=4.25"]
mqtt = ["paho-mqtt>=2.0"]
Expand Down Expand Up @@ -108,7 +108,7 @@ cloud = [
"azure-mgmt-containerinstance>=10.1",
]
all = [
"PySide6==6.11.2", "qt-material",
"PySide6==6.12.0", "qt-material",
"websocket-client>=1.6",
"grpcio>=1.60", "protobuf>=4.25",
"paho-mqtt>=2.0",
Expand Down
38 changes: 30 additions & 8 deletions test/test_dashboard_ui.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import json
import re
import shutil
import time
import os
import subprocess

from gevent.monkey import get_original

import pytest

from je_load_density.utils.dashboard.live_dashboard import _HTML


def test_dashboard_renders_bands_and_untrusted_names_as_text():
def test_dashboard_renders_bands_and_untrusted_names_as_text(tmp_path):
node = shutil.which("node")
if node is None:
pytest.skip("Node is required for browser-script DOM contract checks")
Expand Down Expand Up @@ -45,11 +49,29 @@ class EventSource { constructor() { source=this; } }
"latency_windows": [{**window, "start_time": 0},
{**window, "start_time": 1, "p50_ms": None, "p95_ms": None, "p99_ms": None},
{**window, "start_time": 2}]}
# Security audit: PATH-resolved Node runs fixed harness code; report/name payloads arrive on stdin, never shell
# code.
# nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit.dangerous-subprocess-use-audit
completed = subprocess.run([node, "-e", harness], input=json.dumps({"script": script, "data": data}),
text=True, encoding="utf-8", capture_output=True, timeout=10, check=False)
assert completed.returncode == 0, completed.stderr
output = json.loads(completed.stdout)
output = _run_node(node, harness, {"script": script, "data": data}, tmp_path)
assert output == {"name": name, "bands": 4, "status": "Connected", "failure": "1"}


def _run_node(node, harness, payload, tmp_path):
"""Use native process polling; gevent's Windows wait can miss process exit."""
source = tmp_path / "input.json"
source.write_text(json.dumps(payload), encoding="utf-8")
stdout, stderr = tmp_path / "stdout.json", tmp_path / "stderr.txt"
# gevent replaces POSIX fork internals, so its process creator must remain active there.
popen = get_original("subprocess", "Popen") if os.name == "nt" else subprocess.Popen
with source.open("rb") as incoming, stdout.open("wb") as outgoing, stderr.open("wb") as errors:
# PATH-resolved Node executes fixed fixture code. Report data is read from
# an owned input file, never interpolated into command or shell text.
with popen([node, "-e", harness], stdin=incoming, stdout=outgoing, stderr=errors) as child:
deadline = time.monotonic() + 10
try:
while child.poll() is None:
if time.monotonic() >= deadline:
pytest.fail("Node DOM fixture did not exit within 10 seconds")
time.sleep(0.01)
finally:
if child.poll() is None:
child.kill()
assert child.returncode == 0, stderr.read_text(encoding="utf-8")
return json.loads(stdout.read_text(encoding="utf-8"))
Loading