Skip to content

Proposal: undefined detection in safe/debug builds #15585

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
mlugg opened this issue May 5, 2023 · 7 comments
Closed

Proposal: undefined detection in safe/debug builds #15585

mlugg opened this issue May 5, 2023 · 7 comments

Comments

@mlugg
Copy link
Member

mlugg commented May 5, 2023

Motivation

In the status quo, all undefined bytes are set to 0xAA in safe builds. This is good for manual detection with a debugger, and is helpful in cases where all values are valid, such as power-of-two-sized integers: but in a lot of cases, we can do better. For a lot of types, certain values are invalid, so can be dedicated by the compiler as the undefined value. This would allow undefined to be correctly propagated through values in safe builds, and ultimately give us runtime safety checks for branching on undefined. In particular, something like this might have allowed us to much more easily identify the CI failures which @jacobly0 just tracked down.

Representations

Our goal here is to identify any type with an unused value which we can reserve to mean undefined.

bool can use a padding bit.

A type uX or iX where X is not power of two or is under 8 (i.e. X = 8, 16, 32, ...) can use a padding bit.

An exhuastive enum with unused tags can have a dummy tag added to represent undefined. A non-exhaustive enum defers to the above rule for its tag type.

A struct should not itself be marked as undefined, but rather all of its fields (where possible) should be recursively marked as such. A union or union(enum) can have its tag set to undefined where possible, or have an extra bit otherwise. There's nothing we can (consistently) do for a packed struct, packed union, extern struct, or extern union.

An undefined array is equivalent to an array full of undefined values.

We can't do anything about "standard" (ABI-allowed) nullable pointers, but slices could be made larger if necessary. For non-nullable pointers and slices, we could use the null pointer value.

Other optionals can use a padding bit.

Error sets can use a special tag (maybe maxInt(u16) or similar) to represent undefined. Error unions could do the same.

Vectors, like arrays, can set their elements to undefined, but of course this will only work for base types which are an int type with a non-power-of-two number of bits (at least 8).

I don't know how async frames are represented, but we can surely just add an extra bit if necessary.

That leaves the following non-zero-bit runtime types which we can't represent undefined for:

  • uX/iX for X = 8, 16, 32, 64, ...
  • f32/f64/f80
  • [*c]T, ?*T, ?[*]T
  • packed struct, packed union
  • extern struct, extern union
  • non-exhaustive (or exhaustive-with-all-tags-used) enum(T) where T is one of the int types above
  • any array or vector of the above

That's actually not bad! Most "interesting" types can represent undefined. Even if we don't want to increase the size of anything compared to today, that only excludes a few more types: some unions, nullable slices, and some non-pointer optionals. (Related to the last case: #104).

Drawbacks

As noted above, doing this for as many types as possible would make several types take up more memory. But in addition, the extra checks on many operations could considerably slow down builds with runtime safety. For that reason, it might be worth considering only doing this in Debug builds (i.e. not in ReleaseSafe).

Another downside is that this could make noticing undefined values in a debugger slightly harder, since they will no longer necessarily be 0xAA (although I think it makes sense to continue using that byte pattern where we can't have a specific representation). I'm not really familiar with how debug info works: perhaps we could represent these values in the debug info, and have our pretty-printers write them as undefined?

There's one last, slightly more subtle, drawback. Consider the code var a: u32 = undefined; const b = a & 0;. Here, b is actually totally well-defined, but naive checks would mark it as being undefined. There are issues like this for all kinds of operations, such as @max(undefined_u32, runtime_zero), undef ^ undef, etc. This gets worse when you consider that it's possible for only some bits to be undefined: for instance, undef & 1 gives a result where all but the LSB are defined. Comptime evaluation currently doesn't handle these cases either, and may well never do - but even if it does, handling them at runtime seems infeasible, since it would require storing the defined state of every single bit, effectively doubling the size of everything in memory. However, in practice, I don't think this is much of an issue: code like this is incredibly rare in reality, and if you were confident in your code's correctness, you could do something like if (std.debug.runtime_safety) 0 else undefined. Perhaps there could even be a helper function in std.mem for this:

pub inline fn undefInRelease(x: anytype) @TypeOf(x) {
    return if (std.debug.runtime_safety) x else undefined;
}
@InKryption
Copy link
Contributor

I think you've got exhaustive vs non-exhuastive mixed up in these two passages:

A non-exhuastive enum can have a dummy tag added to represent undefined. An exhaustive enum defers to the above rule for its tag type.

  • exhaustive enum(T) where T is one of the int types above

Also, in reference to:

There's nothing we can (consistently) do for a packed struct, packed union, extern struct, or extern union.

This isn't entirely true. packed structs could follow the same rule as their backing integer (non powers of two or less than 8 bits). Same with packed union I imagine?
Also, more of an inquiry: could we use padding bits/bytes in extern types?

Also, not explicitly mentioned in the proposal, but a thought on optional slices: could we represent an undefined ?[]T as .{ .ptr = null, .len = std.math.maxInt(usize) }?

@IntegratedQuantum
Copy link
Contributor

We can't do anything about "standard" (ABI-allowed) nullable pointers

Not sure how well this translates to other operating systems or embedded, but couldn't we set it to 1?
Given that 0 or null is an invalid pointer everywhere, operating systems cannot give you the rest of page that contains 0 either, so any value between 1 and pageSize-1 should be usable for special cases like undefined.

@mlugg
Copy link
Member Author

mlugg commented May 5, 2023

@InKryption

I think you've got exhaustive vs non-exhuastive mixed up in these two passages:

Yep, serves me right for writing a proposal under time pressure - edited.

Also, in reference to:

There's nothing we can (consistently) do for a packed struct, packed union, extern struct, or extern union.

This isn't entirely true. packed structs could follow the same rule as their backing integer (non powers of two or less than 8 bits). Same with packed union I imagine? Also, more of an inquiry: could we use padding bits/bytes in extern types?

Good point on the packed stuff. Extern types seem like they could use padding bits/bytes upon first glance, but this unfortunately falls apart because pointers exist. One reason we want to mark all struct fields separately is that if you take a pointer to a field and mutate it, you want to mark the field as defined. If you have one defined tag for the whole struct, that's not possible, because the pointer "loses" the information about the struct.

Also, not explicitly mentioned in the proposal, but a thought on optional slices: could we represent an undefined ?[]T as .{ .ptr = null, .len = std.math.maxInt(usize) }?

That seems reasonable to me - again, this is highly related to the optional optimisation stuff in #104.

@IntegratedQuantum

We can't do anything about "standard" (ABI-allowed) nullable pointers

Not sure how well this translates to other operating systems or embedded, but couldn't we set it to 1?

That would probably work on most (all?) "proper" OSes - in fact the Zig compiler currently uses a trick like this for efficiently representing some internal datastructures! - but imo we don't really want to depend on details of the target OS in the language itself for features like this. Standard nullable pointers are actually kinda rare to see in Zig, so I don't think this is a huge loss.

@rohlem
Copy link
Contributor

rohlem commented May 5, 2023

duplicate (concretization) of #211

@mlugg
Copy link
Member Author

mlugg commented May 5, 2023

(closing as duplicate)

@mlugg mlugg closed this as not planned Won't fix, can't repro, duplicate, stale May 5, 2023
@m13253
Copy link

m13253 commented May 6, 2023

I have a question that is related to this issue (but not the linked dup one):
Why does Zig choose to use 0xAA as the undefined marker byte instead of 0xCC?

As far as I know, Microsoft’s C compiler emits 0xCC as marker byte in Debug mode [1].
They originally chose 0xCC because it decompiles into the x86 instruction “int3”, so the debugger will interrupt in case the CPU tries to execute the padding bytes (back when DEP wasn’t a thing yet).

However, although the modern CPUs protects against executing data sections, 0xCC has already become a sub-culture and many programmers are already able to spot it during a debugging session:

  • Google search “858993460” immediately gives you a lot of questions and answers about this bug [2].
    (Actually it’s −858993460, but you can’t search negative numbers on Google.)
  • DuckDuckGo search “0xCCCCCCCC” brings you to a Wikipedia page that explains it [3].
  • Similarly, “╠╠╠╠” [4], “쳌쳌쳌쳌” [5], “昍昍昍昍” [6].
  • “ÌÌÌÌÌÌÌÌ” [7] also leads to results, but are harder to find due to search engine’s Unicode normalization.
  • “Why does my string consist of this Korean character repeated over and over? – The Old New Thing” [8]
  • Japanese C/C++ developers feel funny about “フフフフフフフフ” [9], as it’s their laughing sound.
  • But nothing competes with “烫烫烫烫” (hot hot hot hot) [10], as almost all Chinese developers know this nursery:
    “手持两把锟斤拷, 口中疾呼烫烫烫。” (“Holding two metal pieces of � [11], I shouted: Hot! Hot! Hot!”)

That is to say, 0xCC is a well-known value with a long history and a sub-culture. When a novice developer learns to code and sees this, searching this value (either in numeric form or text form at any encoding other than UTF-8) will lead them to the solution.
However, using 0xAA won’t lead these young programmers into anything: I searched “1431655766”, “2863311530”, “6148914691236517206”, and “12297829382473034410”. Only the third number led me to a couple of web pages owned by LLVM [12] or GCC [13], none of them explains anything related to uninitialized variables.


Screenshot of a debugger
Image: Screenshot of a C++ debugger. [Image source]


TL;DR: I want to propose we modify 0xAA into 0xCC.

P.S.: Should I open a new issue about this idea?
Update: It’s now a new issue: #15603

@leecannon
Copy link
Contributor

@m13253 make a new issue

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants