siding 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CLAUDE.md +248 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +182 -0
- data/Rakefile +51 -0
- data/exe/siding +5 -0
- data/lib/siding/boot_component.rb +18 -0
- data/lib/siding/cli.rb +459 -0
- data/lib/siding/client.rb +417 -0
- data/lib/siding/error.rb +5 -0
- data/lib/siding/invocation.rb +35 -0
- data/lib/siding/life_cycle.rb +114 -0
- data/lib/siding/load_manifest.rb +475 -0
- data/lib/siding/logger.rb +89 -0
- data/lib/siding/platform.rb +37 -0
- data/lib/siding/project_key.rb +64 -0
- data/lib/siding/protocol.rb +154 -0
- data/lib/siding/restarter.rb +205 -0
- data/lib/siding/runtime.rb +165 -0
- data/lib/siding/server.rb +398 -0
- data/lib/siding/staleness.rb +191 -0
- data/lib/siding/version.rb +5 -0
- data/lib/siding/watch.rb +115 -0
- data/lib/siding/worker.rb +266 -0
- data/lib/siding.rb +27 -0
- metadata +97 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: cc7055b7dfa4eed27f2595530e7deb5046c8fbd53e524f9a0574e1bcae70b369
|
|
4
|
+
data.tar.gz: 1f9aa47e9f32bda598a5b7b4ed74238f737b3f3fdc3159d1a69431c4da64964c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fca7785f2b8b84617ebd45de63bd66761c6e828f7ef4529adb346a0643c182a68d68d1a203cfb291381184cc386b260de23465a3df3d57493b92e64745329776
|
|
7
|
+
data.tar.gz: c8be2d9e4a69711d3c6cafb492f34b5c86931f680bbc8797f66760b85cb87894d2fa63972cafd0fd0ade5f8ab2216bdf515c453f95aeab6c865992da83dd34e3
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Siding — working notes
|
|
2
|
+
|
|
3
|
+
A Rails application preloader whose design point is a single guarantee: **it never serves stale
|
|
4
|
+
code**. Everything below exists to keep that true. When a change would trade the guarantee for
|
|
5
|
+
speed, convenience, or a configuration option, the change is wrong — that is Principle I, and it is
|
|
6
|
+
not negotiable.
|
|
7
|
+
|
|
8
|
+
This file is binding, not advisory. Code comments cite the principles and invariants below by
|
|
9
|
+
number, and those citations resolve to this file. Every guarantee stated here must have a test that
|
|
10
|
+
fails when it stops being true; adding or moving a guarantee means adding or moving that test.
|
|
11
|
+
|
|
12
|
+
## Principles
|
|
13
|
+
|
|
14
|
+
### I. Correctness Over Speed (NON-NEGOTIABLE)
|
|
15
|
+
|
|
16
|
+
Siding exists to make development faster, not faster than it is correct. Where the two conflict,
|
|
17
|
+
correctness wins — every time, without a configuration option to decide otherwise.
|
|
18
|
+
|
|
19
|
+
- MUST NOT execute a command against application state that predates the developer's most recent
|
|
20
|
+
saved edit.
|
|
21
|
+
- MUST NOT offer a setting, flag, or environment variable that trades staleness for speed. A knob
|
|
22
|
+
that disables a correctness check will be found, enabled, and then blamed on us.
|
|
23
|
+
- Correctness mechanisms MUST be structural, not procedural. A guarantee that depends on a
|
|
24
|
+
developer maintaining a list, or a reviewer remembering to check, is not a guarantee. Prefer
|
|
25
|
+
designs where the incorrect state is unrepresentable.
|
|
26
|
+
- When correctness forces a slow path, the system MUST say so within a bounded time. Silent
|
|
27
|
+
slowness is indistinguishable from a hang.
|
|
28
|
+
- Optimizations that reduce the cost of a correct path are welcome. Optimizations that skip a
|
|
29
|
+
correctness check are rejected regardless of measured benefit.
|
|
30
|
+
|
|
31
|
+
**Rationale**: the established tools in this space are fast but not trustworthy, so developers
|
|
32
|
+
preemptively disable them. A preloader that serves stale code has negative value — it costs more
|
|
33
|
+
debugging time than it saves in boot time, and trust is not recovered by a benchmark.
|
|
34
|
+
|
|
35
|
+
### II. Executable Guarantees
|
|
36
|
+
|
|
37
|
+
Any claim the project makes about its own behavior MUST be enforced by an automated test in CI. A
|
|
38
|
+
guarantee that is not executed is a hope.
|
|
39
|
+
|
|
40
|
+
- Every MUST-level requirement, and every absolute success criterion ("zero stale results", "no
|
|
41
|
+
orphaned processes", "byte-identical output"), MUST map to a test that fails if it breaks.
|
|
42
|
+
- Absolute claims MUST be tested against the real thing. Where fidelity to a real environment is
|
|
43
|
+
the property under test, mocks are not acceptable evidence.
|
|
44
|
+
- Failure and degraded paths MUST be tested, not merely implemented — that code otherwise runs
|
|
45
|
+
unobserved on other people's machines.
|
|
46
|
+
- A skipped, quarantined, or pending test is a guarantee that no longer holds. Fix it, or withdraw
|
|
47
|
+
the claim from the documentation.
|
|
48
|
+
|
|
49
|
+
### III. Code Quality
|
|
50
|
+
|
|
51
|
+
Code MUST be written to be read by the next person to debug it under time pressure. This is systems
|
|
52
|
+
code involving processes, signals, and file descriptors, where bugs are intermittent and expensive
|
|
53
|
+
to reproduce.
|
|
54
|
+
|
|
55
|
+
- New code MUST match the surrounding conventions — naming, structure, comment density, idiom.
|
|
56
|
+
Consistency outranks personal preference.
|
|
57
|
+
- Public API MUST be minimal and explicitly declared. Anything not documented as public is private
|
|
58
|
+
and may change freely.
|
|
59
|
+
- Every configuration option MUST justify itself against the cost of another code path to keep
|
|
60
|
+
correct. Absence of an option is the default.
|
|
61
|
+
- Dependencies MUST be justified; prefer the standard library. A dependency added here is imposed
|
|
62
|
+
on every project that installs the tool (see Invariant 9).
|
|
63
|
+
- Comments MUST explain why, not what. Code needing a comment to explain what it does should be
|
|
64
|
+
rewritten.
|
|
65
|
+
- Error messages MUST identify what failed, what the system did about it, and what the user can do
|
|
66
|
+
next. An error the user cannot act on is a bug in the error.
|
|
67
|
+
|
|
68
|
+
### IV. User Experience Consistency
|
|
69
|
+
|
|
70
|
+
The tool MUST behave the same way every time, and the way the tools it wraps already behave.
|
|
71
|
+
Surprise is the failure mode. A tool sitting between a developer and their command inherits blame
|
|
72
|
+
for everything downstream; the only defense is being ruled out quickly, and being trivially
|
|
73
|
+
removable when it cannot be.
|
|
74
|
+
|
|
75
|
+
- Accelerated execution MUST be observationally indistinguishable from unaccelerated execution,
|
|
76
|
+
except faster (Invariant 5).
|
|
77
|
+
- Logger MUST NOT reach stdout, stderr, or any stream the user redirected; they go to the
|
|
78
|
+
controlling terminal, visually distinct from application output (Invariant 4). With no
|
|
79
|
+
controlling terminal there is no developer to inform and the notice is dropped, but it MUST
|
|
80
|
+
remain recoverable from `siding status`.
|
|
81
|
+
- MUST NOT require a ritual. Any reachable state MUST recover automatically on the next
|
|
82
|
+
invocation — no manual cleanup, restart, or stop command.
|
|
83
|
+
- MUST be disableable for a single invocation and for a whole session, without editing files
|
|
84
|
+
committed to the repository.
|
|
85
|
+
- MUST be able to explain its own behavior on request: whether acceleration was used, against what
|
|
86
|
+
state, and why or why not.
|
|
87
|
+
- When the tool cannot do its job, the user's command MUST still run (Invariant 6).
|
|
88
|
+
|
|
89
|
+
Naming stderr as the logger destination reads as the obvious answer and is wrong: stderr
|
|
90
|
+
belongs to the command, not to the tool. Once a developer redirects it, "write to stderr" and "never
|
|
91
|
+
pollute redirected output" cannot both hold, and the only way to obey both is silence — which
|
|
92
|
+
Principle I prohibits. The controlling terminal is the one channel that belongs to the developer
|
|
93
|
+
rather than to their command.
|
|
94
|
+
|
|
95
|
+
## Constraints
|
|
96
|
+
|
|
97
|
+
**Platform**: Linux and macOS. WSL is supported and treated as Linux. Windows is not supported, and
|
|
98
|
+
this MUST be stated explicitly rather than manifest as degraded behavior.
|
|
99
|
+
|
|
100
|
+
**Runtime dependencies**: code on the latency-critical path MUST NOT load Bundler or the application
|
|
101
|
+
framework. What may be declared is governed by Principle III and Invariant 9.
|
|
102
|
+
|
|
103
|
+
**Isolation**: runtime state MUST be per-user and user-owned. Never shared across users, project
|
|
104
|
+
checkouts, tool versions, or language runtime versions.
|
|
105
|
+
|
|
106
|
+
**Compatibility**: the supported Ruby floor is declared in the gemspec and MUST NOT be raised in a
|
|
107
|
+
patch release. EOL Ruby and framework versions are out of scope.
|
|
108
|
+
|
|
109
|
+
## Invariants
|
|
110
|
+
|
|
111
|
+
Break one of these and the tool becomes the thing it was written to replace. They are the
|
|
112
|
+
codebase-specific shape of the principles above; where an invariant looks arbitrary, the principle
|
|
113
|
+
it serves is the reason.
|
|
114
|
+
|
|
115
|
+
1. **Validation precedes fork.** Every invocation revalidates the manifest before a worker exists.
|
|
116
|
+
Filesystem watching (`Restarter`) is an optimization on the *rebuild*, never on the *decision*.
|
|
117
|
+
2. **No knob turns validation off.** No flag, no environment variable, no config file.
|
|
118
|
+
`test/unit/platform_test.rb` scans `lib/**/*.rb` for per-project config file reads and fails.
|
|
119
|
+
3. **The watch set is derived, never declared.** `LoadManifest` is the `$LOADED_FEATURES` delta
|
|
120
|
+
across boot, plus autoload/eager-load roots, `config/initializers`, `Gemfile.lock`, the resolved
|
|
121
|
+
gem set, and boot-component environment. `Siding.boot_component` is a residue escape hatch — if
|
|
122
|
+
ordinary application code needs it, the derivation has a bug.
|
|
123
|
+
4. **The tool never writes to stdout or stderr.** Everything goes to `/dev/tty` via `Logger`.
|
|
124
|
+
`SIDING_LOG` changes verbosity, never destination. Application boot errors are the exception,
|
|
125
|
+
because an unaccelerated run would put them on stderr too.
|
|
126
|
+
5. **An accelerated run is observationally identical to an unaccelerated one.** Same bytes on each
|
|
127
|
+
stream in the same order, same exit status including death by signal, same tty behaviour.
|
|
128
|
+
6. **The tool is never the reason a command fails.** Unsupported platform, unusable runtime
|
|
129
|
+
directory, a boot that will not finish, a protocol version mismatch — each degrades to running
|
|
130
|
+
the command unaccelerated.
|
|
131
|
+
7. **Runtime state is per-user and owner-only.** `XDG_RUNTIME_DIR` or `~/.local/state/siding`, mode
|
|
132
|
+
`0700`, never a shared temp directory. A warm application reachable by another user is the same
|
|
133
|
+
class of failure as stale code. Every file under there is a hint: confirm a pid is live before
|
|
134
|
+
using it, and recover from any leftover combination without asking a developer to clean up.
|
|
135
|
+
8. **No orphaned processes.** `ProcessHelpers#assert_no_surviving_processes` runs in the teardown of
|
|
136
|
+
*every* integration test, not just the ones about life cycle.
|
|
137
|
+
9. **`watchcat` is the one runtime gem dependency; standard library otherwise.** `siding.gemspec`
|
|
138
|
+
depends on it directly, so `bundle install` resolves it the moment `gem "siding"` is added — no
|
|
139
|
+
separate step, no silent gap. It backs *both* `SIDING_WATCH` backends (`events`, native OS
|
|
140
|
+
notification; `poll`, watchcat's `force_polling`), so `Restarter`'s wake source is watchcat or
|
|
141
|
+
nothing — never a home-grown poll loop that behaves differently depending on what happened to be
|
|
142
|
+
installed, which is what made `SIDING_WATCH=poll` and "watchcat isn't installed" the same code
|
|
143
|
+
path before the dependency was declared. Reimplementing cross-platform file watching in the
|
|
144
|
+
standard library is a worse trade than depending on the one gem written for this project's own
|
|
145
|
+
fork-safety needs (see `restarter.rb`'s `around_fork`). Every other dependency remains
|
|
146
|
+
disallowed.
|
|
147
|
+
|
|
148
|
+
## Layout
|
|
149
|
+
|
|
150
|
+
| File | Role |
|
|
151
|
+
|---|---|
|
|
152
|
+
| `exe/siding` | Entry point. Requires exactly one file — the client's load time is a floor on the speedup |
|
|
153
|
+
| `lib/siding.rb` | The public API surface, and *not* what the client loads |
|
|
154
|
+
| `lib/siding/client.rb` | The process the developer runs. Loads no gems, no Bundler, no application. `DEFAULT_BOOT_TIMEOUT = 90.0` (`SIDING_TIMEOUT`), `NOTICE_AFTER = 0.75` |
|
|
155
|
+
| `lib/siding/cli.rb` | Argument handling, management commands, the `doctor` / `status` reports |
|
|
156
|
+
| `lib/siding/server.rb` | The long-lived booted application. `setsid` first: no controlling terminal, own process group. Validates, forks, hands off — never relays I/O |
|
|
157
|
+
| `lib/siding/worker.rb` | One invocation in a fork. Takes the developer's descriptors, env, cwd, signals; leaves the server's process group. Cannot be constructed without a verdict |
|
|
158
|
+
| `lib/siding/load_manifest.rb` | What the boot actually loaded. The central design decision |
|
|
159
|
+
| `lib/siding/staleness.rb` | `fresh` / `reloadable` / `reboot`, with reasons and trigger paths |
|
|
160
|
+
| `lib/siding/restarter.rb` | Speculative reboot while idle. Allowed to be wrong; can only cost speed. Wakes on filesystem events or a poll backoff (`Watch`, `SIDING_WATCH`) — the wake source, never what a boot is validated against |
|
|
161
|
+
| `lib/siding/watch.rb` | Backend selection and the watchcat life cycle for `Restarter`'s wake source only — no staleness logic. `SIDING_WATCH=poll` drives watchcat with `force_polling: true`. watchcat is a declared dependency (Invariant 9), so its *absence* is not a case to handle; a watcher that cannot *start* — an environment problem — degrades to `Restarter`'s own interval backoff, because Principle I only asks watchcat to cost speed, never correctness |
|
|
162
|
+
| `lib/siding/runtime.rb` | The on-disk layout and its permissions |
|
|
163
|
+
| `lib/siding/project_key.rb` | app_root + uid + tool version + ruby version + app env. Every field names a way a warm application could serve a command it has no business serving |
|
|
164
|
+
| `lib/siding/protocol.rb` | Wire protocol. `VERSION = 1`, versioned independently of the gem |
|
|
165
|
+
| `lib/siding/logger.rb` | The `/dev/tty` rule, in code |
|
|
166
|
+
| `lib/siding/life cycle.rb` | `before_fork` / `after_fork`. The only configuration hook in the project |
|
|
167
|
+
| `lib/siding/platform.rb` | Support detection. Deliberately has **no** Rails version check — resolution already makes a below-floor install unreachable |
|
|
168
|
+
| `lib/siding/invocation.rb` | Reads the `SIDING_RESOLUTION` / `_REVISION` / `_BOOT_SECONDS` env keys back out, so introspection is truthful unaccelerated too |
|
|
169
|
+
| `lib/siding/boot_component.rb` | The `Siding.boot_component` registry (Invariant 3) |
|
|
170
|
+
|
|
171
|
+
`reloadable` vs `reboot` is decided from the booted application's own configuration — a file under
|
|
172
|
+
an autoload path *when reloading is actually enabled* is repaired by Rails' reloader in the worker.
|
|
173
|
+
Directory layout alone does not decide it.
|
|
174
|
+
|
|
175
|
+
## Tests
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
bundle exec rake test # unit + integration; green on every commit
|
|
179
|
+
bundle exec rake test:unit # in-process only; fastest loop
|
|
180
|
+
bundle exec rake test:integration # drives exe/siding as a subprocess
|
|
181
|
+
bundle exec rake test:pty # interactive fidelity; needs a real pty
|
|
182
|
+
|
|
183
|
+
SIDING_SOAK=1 bundle exec ruby -Ilib -Itest test/integration/soak_test.rb # opt-in, ~2 min
|
|
184
|
+
bundle exec ruby -Ilib -Itest test/unit/staleness_events_test.rb -n /pattern/ # one file or test
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The suites split by *character*, not by mirrored source path: `test/unit/`, `test/integration/`,
|
|
188
|
+
`test/pty/`.
|
|
189
|
+
|
|
190
|
+
Things that will bite you:
|
|
191
|
+
|
|
192
|
+
- **The fixture application is a prerequisite, not a fixture file.** `test/fixtures/rails_app` has
|
|
193
|
+
its own Gemfile and sqlite databases, neither in version control. `rake fixture:prepare` is a
|
|
194
|
+
prerequisite of the test tasks and is cheap when there is nothing to do. Skipping it fails
|
|
195
|
+
quietly: with no bundle, accelerated and unaccelerated runs fail the same way, and every
|
|
196
|
+
output-identity comparison passes by comparing two identical failures.
|
|
197
|
+
- **Integration tests run the real executable** through `TestSupport#siding_invoke` /
|
|
198
|
+
`#unaccelerated_invoke`. Byte-identity, exit status, and process cleanup are properties of a
|
|
199
|
+
process and cannot fail in a way an in-process call would notice.
|
|
200
|
+
- **`siding_env` nils out** `SIDING_DISABLE`, `SIDING_LOG`, and the bundler variables.
|
|
201
|
+
- **Anything asserting on the tool's own output needs a pty.** `include TerminalTests`, then
|
|
202
|
+
`open_siding_terminal` + `expect_on(session, pattern, timeout:)` + `session.screen`. A piped
|
|
203
|
+
`siding doctor` or `siding status` produces nothing by contract; the exit status is the part a
|
|
204
|
+
script reads, and that is checked through the pipe.
|
|
205
|
+
- **Tests inspect the runtime directory directly** (`siding_state_dir`, `siding_server_info`,
|
|
206
|
+
`warm_application?`, `siding_server_pid`) rather than asking the tool, because "is something
|
|
207
|
+
warm?" has to be answerable when the tool is what is under suspicion.
|
|
208
|
+
- **A timing bound is proved by squeezing the bound, not by slowing the subject** — see the
|
|
209
|
+
boot-timeout test in `test/integration/degradation_test.rb`, which sets `SIDING_TIMEOUT=0.05`
|
|
210
|
+
rather than waiting 90 seconds, then waits for the abandoned boot to finish publishing so it does
|
|
211
|
+
not leak into the next test.
|
|
212
|
+
- **`SIDING_WATCH` gets both modes run for real.** `test/unit/restarter_test.rb` parameterizes the
|
|
213
|
+
decision tests over `events` and `poll`; `test/integration/restarter_test.rb` does the same end to
|
|
214
|
+
end. `bundle exec rake test:integration` alone only proves the default; add
|
|
215
|
+
`SIDING_WATCH=poll bundle exec rake test:integration` for the poll backend. The fixture lists
|
|
216
|
+
`watchcat` directly in its own Gemfile because it never adds `gem "siding"` itself
|
|
217
|
+
(`test/test_helper.rb` invokes `exe/siding` by absolute path, outside Bundler, so siding's gemspec
|
|
218
|
+
dependencies never reach the fixture's lockfile).
|
|
219
|
+
|
|
220
|
+
There is no performance suite. Wall-clock budgets and the benchmarks enforcing them were removed;
|
|
221
|
+
what remains are the correctness properties, which is why `test/integration/` asserts on *where*
|
|
222
|
+
code ran rather than how long it took. A change made for speed is currently unmeasured — if that
|
|
223
|
+
becomes a problem, the honest fix is to bring the benchmarks back, not to reason about it.
|
|
224
|
+
|
|
225
|
+
## Environment surface
|
|
226
|
+
|
|
227
|
+
`SIDING_DISABLE`, `SIDING_TIMEOUT`, `SIDING_IDLE_TIMEOUT`, `SIDING_LOG`, `SIDING_SERVER`,
|
|
228
|
+
`SIDING_RESOLUTION`, `SIDING_REVISION`, `SIDING_BOOT_SECONDS`, `SIDING_WATCH`. That list is asserted
|
|
229
|
+
by an allowlist scan in `test/unit/config_test.rb` — adding a variable without adding it there fails
|
|
230
|
+
the build, which is the point.
|
|
231
|
+
|
|
232
|
+
## Before merge
|
|
233
|
+
|
|
234
|
+
1. All tests pass, with none skipped or quarantined to achieve it.
|
|
235
|
+
2. Every new MUST-level requirement has an automated test (Principle II).
|
|
236
|
+
3. Public API changes are documented, including what is deliberately not public. Public API is the
|
|
237
|
+
life cycle hooks, `boot_component`, and introspection — what `lib/siding.rb` exposes and documents
|
|
238
|
+
as public. Everything else under `Siding::` may change.
|
|
239
|
+
4. Any new configuration option carries a written justification (Principle III).
|
|
240
|
+
5. Comments name the principle or invariant that forced the shape, at the density already in the
|
|
241
|
+
surrounding file — it is high, deliberately.
|
|
242
|
+
|
|
243
|
+
Repository artifacts — code, comments, README, commit messages — are written in English.
|
|
244
|
+
|
|
245
|
+
A deviation from a principle MUST name the simpler alternative and why it was insufficient, in the
|
|
246
|
+
commit that introduces it. Amending a principle to weaken or remove it MUST document what problem it
|
|
247
|
+
was preventing and why that problem no longer applies; rewording that does not change what is
|
|
248
|
+
permitted needs no justification.
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"siding" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at ["yuuji.yaginuma@gmail.com"](mailto:"yuuji.yaginuma@gmail.com").
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yuji Yaginuma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Siding
|
|
2
|
+
|
|
3
|
+
An alternative Rals application preloader that focuses to never serves state code.
|
|
4
|
+
|
|
5
|
+
Siding's design point is that guarantee. It derives the set of files it watches from what the boot
|
|
6
|
+
actually loaded, rather than from a maintained list, and it revalidates before every invocation. So
|
|
7
|
+
"you never run against stale code" is structural rather than best-effort, and when it cannot
|
|
8
|
+
accelerate, your command still runs — unaccelerated, never refused.
|
|
9
|
+
|
|
10
|
+
## Design
|
|
11
|
+
|
|
12
|
+
**The watch set is the whole argument.** A hand-maintained list is incomplete by construction: it is
|
|
13
|
+
maintained by a person while the boot process is free to load anything. All files are loaded at boot,
|
|
14
|
+
none is watched, and all of them persist across invocations after you change them. You then debug
|
|
15
|
+
behavior that does not match the source in front of you. Deriving the set from what boot *actually loaded*
|
|
16
|
+
makes it complete instead, because the program is observing itself rather than trusting a list.
|
|
17
|
+
|
|
18
|
+
**Checking on every invocation is what makes it a guarantee.** Filesystem events are missable sometimes.
|
|
19
|
+
So a watcher can only ever be best-effort, so Siding uses one solely to get a head start on the rebuild,
|
|
20
|
+
never to decide whether the application is current. That decision is made by revalidating before the fork,
|
|
21
|
+
on the path that cannot be skipped. Siding uses filesystem events by default, but you can switch it
|
|
22
|
+
by `SIDING_WATCH`. If you want to use polling, please specify `poll`.
|
|
23
|
+
|
|
24
|
+
## Supported versions and platforms
|
|
25
|
+
|
|
26
|
+
| | Supported | Below the line |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| Platform | Linux, macOS (WSL counts as Linux) | Commands run unaccelerated |
|
|
29
|
+
| Commands | `rails`, `rake`, `rspec`, `test` | Commands run unaccelerated |
|
|
30
|
+
|
|
31
|
+
`test` is Rails' own `bin/test` (minitest); invoke it as `siding test ...`. `siding init` does not
|
|
32
|
+
generate a `bin/test` binstub, since `bin/rails test` already covers the same entry point — prefix
|
|
33
|
+
`test` explicitly when you use it directly.
|
|
34
|
+
|
|
35
|
+
`rails server` (and its alias `rails s`) are accelerated. But, two rails subcommands
|
|
36
|
+
stay below the line: `rails server -d`/`--daemon` (daemonizing detaches from the process siding
|
|
37
|
+
manages, which would leave nothing for it to signal or clean up) and `rails dev:cache` (a
|
|
38
|
+
one-shot toggle of the running application's caching mode, not something to accelerate). Below the
|
|
39
|
+
line means passed through unaccelerated, never refused.
|
|
40
|
+
|
|
41
|
+
`siding status` and `siding doctor` describe the warm *application* process siding manages — not the
|
|
42
|
+
Rails web server you get from `rails server`. Once `rails server` is itself accelerated, it's easy
|
|
43
|
+
to conflate the two: "server" in siding's own output always means the former.
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
Add it to the `development` and `test` groups of your application's `Gemfile`:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
group :development, :test do
|
|
51
|
+
gem "siding"
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Then:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
bundle install
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
That's enough to use it — prefix any command with `siding`, as shown below. Optionally, run
|
|
62
|
+
`bundle exec siding init` to generate `bin/rails`, `bin/rake`, and `bin/rspec` binstub shims, so
|
|
63
|
+
that the plain, unprefixed commands you already run are accelerated automatically. It reports
|
|
64
|
+
exactly which files it wrote.
|
|
65
|
+
|
|
66
|
+
`init` exists purely to remove the friction of remembering the `siding` prefix. Each shim it writes
|
|
67
|
+
is a one-line `exec("siding", ...)` wrapper — nothing else depends on it, deleting the file reverts
|
|
68
|
+
to the plain, unaccelerated command, and an existing `bin/rails` you've edited by hand is never
|
|
69
|
+
overwritten. `siding <command>` behaves identically whether or not you've ever run `init`.
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
Prefix any command you would normally run:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
siding rspec test/models/user_test.rb
|
|
77
|
+
siding rails runner 'puts User.count'
|
|
78
|
+
siding rails db:migrate
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The accelerated run is observationally indistinguishable from the unaccelerated one: same stdout and
|
|
82
|
+
stderr, byte for byte and in the same order; same exit status, including death by signal; the same
|
|
83
|
+
interactive behavior, including debuggers and a real tty.
|
|
84
|
+
|
|
85
|
+
Siding's own logger never appear on stdout or stderr. They go to your controlling terminal, so
|
|
86
|
+
a redirected or piped stream contains exactly what your command wrote and nothing else.
|
|
87
|
+
|
|
88
|
+
### Other commands
|
|
89
|
+
|
|
90
|
+
| Command | What it does |
|
|
91
|
+
|---------|--------------|
|
|
92
|
+
| `siding start` | Boots a warm application without running a command. Idempotent, and never a prerequisite — the first accelerated command boots the same thing |
|
|
93
|
+
| `siding status` | Whether a warm application exists, when it booted, what it has served, and recent staleness events |
|
|
94
|
+
| `siding stop` | Stops everything belonging to this project. Idempotent |
|
|
95
|
+
| `siding restart` | `stop` followed by a fresh boot |
|
|
96
|
+
| `siding doctor` | Why a given invocation was or was not accelerated: platform support, runtime directory state, recent boot failures |
|
|
97
|
+
| `siding init` | Generates binstub shims |
|
|
98
|
+
|
|
99
|
+
### Environment variables
|
|
100
|
+
|
|
101
|
+
| Variable | Effect |
|
|
102
|
+
|----------|--------|
|
|
103
|
+
| `SIDING_DISABLE` | Truthy value runs the invocation unaccelerated. Works per-command and per-shell |
|
|
104
|
+
| `SIDING_TIMEOUT` | Maximum wait for a boot before surfacing the situation rather than hanging |
|
|
105
|
+
| `SIDING_IDLE_TIMEOUT` | Idle period after which the warm application exits (default 15 minutes) |
|
|
106
|
+
| `SIDING_LOG` | Raises diagnostic verbosity. Changes how much is said, never where |
|
|
107
|
+
| `SIDING_WATCH` | `events` (default) or `poll`. Chooses how watchcat wakes a speculative reboot while idle, never what it is validated against |
|
|
108
|
+
|
|
109
|
+
Siding is active in `development` and `test`, and stays inactive in production-like environments.
|
|
110
|
+
The gate is an allowlist: an unrecognized environment name defaults to inactive.
|
|
111
|
+
|
|
112
|
+
There is no option that turns staleness validation off. That is the guarantee the tool is for, and a
|
|
113
|
+
tool that can be put into an unsafe state on purpose will be found in one by accident.
|
|
114
|
+
|
|
115
|
+
## Ruby API
|
|
116
|
+
|
|
117
|
+
A correctly-behaving application needs none of this. It exists for the cases automatic derivation
|
|
118
|
+
cannot reach on its own, and for tooling that wants to know what state a run happened against.
|
|
119
|
+
|
|
120
|
+
**Fork life cycle hooks.** Forking leaves a child with dead background threads and sockets shared
|
|
121
|
+
with its parent. A gem that holds either releases it before the fork and re-establishes it after:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
Siding.before_fork { MyConnectionPool.disconnect! }
|
|
125
|
+
Siding.after_fork { MyConnectionPool.reconnect! }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Boot components declaration.** Siding derives what it watches from what your boot actually loaded,
|
|
129
|
+
so ordinary code needs no declaration. Use this only for what that cannot see — a data file read at
|
|
130
|
+
boot, a generated artifact:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
Siding.boot_component "config/feature_flags.yml"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
If you find yourself needing this for ordinary application code, the derivation has a gap. That is a
|
|
137
|
+
bug worth reporting, not a line worth adding.
|
|
138
|
+
|
|
139
|
+
**Introspection.** Truthful in an unaccelerated run too, so no guard is needed:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
Siding.accelerated? # => true / false
|
|
143
|
+
Siding.resolution # => "fresh", "reloaded_in_worker", "rebuild", or nil
|
|
144
|
+
Siding.revision # => a label for the source state this run served
|
|
145
|
+
Siding.boot_seconds # => how long the warm application took to boot
|
|
146
|
+
Siding.invocation # => all of the above, as a Hash
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Development
|
|
150
|
+
|
|
151
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
bundle exec rake test # unit + integration; expected green on every commit
|
|
155
|
+
bundle exec rake test:pty # interactive fidelity, needs a real pty
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The integration suite drives the real executable against the fixture application in
|
|
159
|
+
`test/fixtures/rails_app/`. Its bundle and databases are a prerequisite of those tasks and are
|
|
160
|
+
prepared automatically on a fresh clone, so there is nothing to install by hand.
|
|
161
|
+
|
|
162
|
+
Before changing behavior, read `CLAUDE.md` — its principles and invariants are binding, not
|
|
163
|
+
advisory.
|
|
164
|
+
|
|
165
|
+
To install the gem onto your local machine, run `bundle exec rake install`. To release a new
|
|
166
|
+
version, update the version number in `version.rb`, then run `bundle exec rake release`.
|
|
167
|
+
|
|
168
|
+
## Contributing
|
|
169
|
+
|
|
170
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/y-yagi/siding. This
|
|
171
|
+
project is intended to be a safe, welcoming space for collaboration, and contributors are expected
|
|
172
|
+
to adhere to the [code of conduct](https://github.com/y-yagi/siding/blob/main/CODE_OF_CONDUCT.md).
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
The gem is available as open source under the terms of the
|
|
177
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
|
178
|
+
|
|
179
|
+
## Code of Conduct
|
|
180
|
+
|
|
181
|
+
Everyone interacting in the Siding project's codebases, issue trackers, chat rooms and mailing lists
|
|
182
|
+
is expected to follow the [code of conduct](https://github.com/y-yagi/siding/blob/main/CODE_OF_CONDUCT.md).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "minitest/test_task"
|
|
5
|
+
|
|
6
|
+
UNIT_GLOBS = ["test/test_*.rb", "test/unit/**/*_test.rb"].freeze
|
|
7
|
+
INTEGRATION_GLOBS = ["test/integration/**/*_test.rb"].freeze
|
|
8
|
+
PTY_GLOBS = ["test/pty/**/*_test.rb"].freeze
|
|
9
|
+
|
|
10
|
+
FIXTURE_APP = File.expand_path("test/fixtures/rails_app", __dir__)
|
|
11
|
+
|
|
12
|
+
Minitest::TestTask.create(:"test:unit") { |t| t.test_globs = UNIT_GLOBS }
|
|
13
|
+
Minitest::TestTask.create(:"test:integration") { |t| t.test_globs = INTEGRATION_GLOBS }
|
|
14
|
+
Minitest::TestTask.create(:"test:pty") { |t| t.test_globs = PTY_GLOBS }
|
|
15
|
+
Minitest::TestTask.create(:test) { |t| t.test_globs = UNIT_GLOBS + INTEGRATION_GLOBS }
|
|
16
|
+
|
|
17
|
+
# The soak exercise, as a task rather than a documented incantation.
|
|
18
|
+
SOAK_GLOBS = ["test/integration/soak_test.rb"].freeze
|
|
19
|
+
Minitest::TestTask.create(:"test:soak") { |t| t.test_globs = SOAK_GLOBS }
|
|
20
|
+
|
|
21
|
+
task :"soak:enable" do
|
|
22
|
+
ENV["SIDING_SOAK"] ||= "1"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "Install the fixture application's bundle and prepare its databases"
|
|
26
|
+
task :"fixture:prepare" do
|
|
27
|
+
Bundler.with_unbundled_env do
|
|
28
|
+
unless system({}, "bundle", "check", chdir: FIXTURE_APP, out: File::NULL, err: File::NULL)
|
|
29
|
+
run_in_fixture({}, "bundle", "install")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
%w[development test].each do |rails_env|
|
|
33
|
+
next if File.exist?(File.join(FIXTURE_APP, "storage", "#{rails_env}.sqlite3"))
|
|
34
|
+
|
|
35
|
+
run_in_fixture({ "RAILS_ENV" => rails_env }, "bundle", "exec", "rails", "db:prepare")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run_in_fixture(env, *command)
|
|
41
|
+
return if system(env, *command, chdir: FIXTURE_APP)
|
|
42
|
+
|
|
43
|
+
raise "#{command.join(' ')} failed in #{FIXTURE_APP}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
task test: :"fixture:prepare"
|
|
47
|
+
task "test:integration" => :"fixture:prepare"
|
|
48
|
+
task "test:pty" => :"fixture:prepare"
|
|
49
|
+
task "test:soak" => [:"soak:enable", :"fixture:prepare"]
|
|
50
|
+
|
|
51
|
+
task default: :test
|
data/exe/siding
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Siding
|
|
4
|
+
module BootComponent
|
|
5
|
+
class << self
|
|
6
|
+
def add(*adding_paths)
|
|
7
|
+
adding_paths.flatten.each do |path|
|
|
8
|
+
expanded = File.expand_path(path.to_s)
|
|
9
|
+
paths << expanded unless paths.include?(expanded)
|
|
10
|
+
end
|
|
11
|
+
paths
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def paths = @paths ||= []
|
|
15
|
+
def reset! = @paths = []
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|